Skip to content

Commit 23f55bb

Browse files
committed
test(mobile): add unit tests and maestro e2e framework
- add 49 unit tests for auth, interceptors, notifications - add maestro e2e test flows (launch, login, host, concierge, notifications) - add mise tasks for test:e2e and test:e2e:smoke
1 parent 563b919 commit 23f55bb

14 files changed

Lines changed: 1320 additions & 0 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ apps/mobile/android/local.properties
6262
apps/mobile/ios/Flutter/Generated.xcconfig
6363
apps/mobile/ios/Flutter/flutter_export_environment.sh
6464

65+
# Maestro
66+
apps/mobile/.maestro/tests/
67+
6568
# Misc
6669
*.bak
6770
*.tmp

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ mise //apps/worker:dev | :test | :lint | :format
5151

5252
# Mobile
5353
mise //apps/mobile:dev | :build | :test | :lint | :format | :gen:api | :gen:l10n
54+
mise //apps/mobile:test:e2e # Run Maestro E2E tests (requires running simulator)
55+
mise //apps/mobile:test:e2e:smoke # Run smoke tests only
5456
```
5557

5658
### Running a single test
@@ -135,6 +137,7 @@ Branch naming: `feature/*`, `fix/*`, `hotfix/*`, `release/*`
135137
| API | pytest + pytest-asyncio | `apps/api/pyproject.toml``asyncio_mode = "auto"`, testpaths: `tests/` |
136138
| Worker | pytest + pytest-asyncio | `apps/worker/pyproject.toml``asyncio_mode = "auto"` |
137139
| Mobile | flutter_test + mocktail | `test/**/*_test.dart` |
140+
| Mobile E2E | Maestro | `apps/mobile/.maestro/*.yaml` — YAML 기반 UI E2E 테스트, 시뮬레이터 필요 |
138141
| Design tokens | Vitest | `packages/design-tokens/` |
139142

140143
## Key Patterns
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
appId: com.example.mobile
2+
tags:
3+
- smoke
4+
---
5+
# Verify app launches and shows splash → login screen
6+
- launchApp
7+
- assertVisible: "쥬니"
8+
# Splash should auto-redirect to login when no session exists
9+
- waitForAnimationToEnd
10+
- assertVisible: "Google로 로그인"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
appId: com.example.mobile
2+
tags:
3+
- auth
4+
---
5+
# Verify login screen UI elements
6+
- launchApp:
7+
clearState: true
8+
- waitForAnimationToEnd
9+
- assertVisible: "쥬니"
10+
- assertVisible: "Google로 로그인"
11+
# Google Sign-In button should be tappable
12+
- assertEnabled: "Google로 로그인"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
appId: com.example.mobile
2+
tags:
3+
- host
4+
---
5+
# Verify host home screen navigation (requires authenticated host session)
6+
# This flow assumes the user is already logged in as host role.
7+
# Use `maestro test --env=AUTH_TOKEN=<token>` or pre-seed auth state.
8+
- launchApp
9+
- waitForAnimationToEnd
10+
11+
# If redirected to login, skip remaining assertions
12+
- runFlow:
13+
when:
14+
visible: "어르신, 안녕하세요"
15+
commands:
16+
# Main menu buttons should be visible
17+
- assertVisible: "라이브 시작"
18+
- assertVisible: "복약 관리"
19+
- assertVisible: "건강 기록"
20+
- assertVisible: "길 안내"
21+
22+
# Navigate to medications
23+
- tapOn: "복약 관리"
24+
- waitForAnimationToEnd
25+
- back
26+
27+
# Navigate to wellness
28+
- tapOn: "건강 기록"
29+
- waitForAnimationToEnd
30+
- back
31+
32+
# Navigate to profile
33+
- tapOn:
34+
id: ".*profile.*"
35+
- waitForAnimationToEnd
36+
- back
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
appId: com.example.mobile
2+
tags:
3+
- host
4+
- concierge
5+
---
6+
# Verify notifications screen (requires authenticated session)
7+
- launchApp
8+
- waitForAnimationToEnd
9+
10+
# Navigate to notifications via app bar icon
11+
- runFlow:
12+
when:
13+
visible:
14+
id: ".*notification.*"
15+
commands:
16+
- tapOn:
17+
id: ".*notification.*"
18+
- waitForAnimationToEnd
19+
- assertVisible: "알림"
20+
21+
# Settings icon should be accessible
22+
- assertVisible:
23+
id: ".*settings.*"
24+
25+
# Either shows notification list or empty state
26+
- runFlow:
27+
when:
28+
visible: "알림이 아직 없어요."
29+
commands:
30+
- assertVisible: "알림이 아직 없어요."
31+
32+
# Navigate to notification settings
33+
- tapOn:
34+
id: ".*settings.*"
35+
- waitForAnimationToEnd
36+
- assertVisible: "알림 설정"
37+
- back
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
appId: com.example.mobile
2+
tags:
3+
- concierge
4+
---
5+
# Verify concierge home screen (requires authenticated concierge session)
6+
- launchApp
7+
- waitForAnimationToEnd
8+
9+
- runFlow:
10+
when:
11+
visible: "보호자 대시보드"
12+
commands:
13+
- assertVisible: "보호자 대시보드"
14+
- assertVisible: "라이브 보기"
15+
16+
# Navigate to live view
17+
- tapOn: "라이브 보기"
18+
- waitForAnimationToEnd
19+
- back

apps/mobile/.maestro/config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
appId: com.example.mobile
2+
name: Juny E2E Tests
3+
tags:
4+
- smoke
5+
- auth
6+
- host
7+
- concierge

apps/mobile/mise.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ format = { run = "dart format .", description = "Format mobile app" }
77
test = { run = "flutter test", description = "Test mobile app" }
88
"gen:l10n" = { run = "flutter gen-l10n", description = "Generate localizations" }
99
"gen:api" = { run = "dart run swagger_parser", description = "Generate API client from OpenAPI" }
10+
"test:e2e" = { run = "maestro test .maestro/", description = "Run Maestro E2E tests" }
11+
"test:e2e:smoke" = { run = "maestro test .maestro/ --include-tags=smoke", description = "Run Maestro smoke tests" }
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import 'package:dio/dio.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:mobile/core/network/interceptors/auth_interceptor.dart';
4+
import 'package:mocktail/mocktail.dart';
5+
6+
// ---------------------------------------------------------------------------
7+
// Minimal handler doubles
8+
// ---------------------------------------------------------------------------
9+
10+
class _FakeRequestOptions extends Fake implements RequestOptions {}
11+
12+
class _MockRequestInterceptorHandler extends Mock
13+
implements RequestInterceptorHandler {}
14+
15+
class _MockErrorInterceptorHandler extends Mock
16+
implements ErrorInterceptorHandler {}
17+
18+
// ---------------------------------------------------------------------------
19+
// Helpers
20+
// ---------------------------------------------------------------------------
21+
22+
DioException _buildDioError({
23+
required RequestOptions options,
24+
int? statusCode,
25+
}) {
26+
final response = statusCode != null
27+
? Response<dynamic>(
28+
requestOptions: options,
29+
statusCode: statusCode,
30+
)
31+
: null;
32+
return DioException(
33+
requestOptions: options,
34+
response: response,
35+
);
36+
}
37+
38+
void main() {
39+
setUpAll(() {
40+
registerFallbackValue(_FakeRequestOptions());
41+
registerFallbackValue(
42+
DioException(requestOptions: RequestOptions()),
43+
);
44+
});
45+
46+
group('AuthInterceptor', () {
47+
group('onRequest', () {
48+
test('adds Authorization header when token is non-empty', () {
49+
final handler = _MockRequestInterceptorHandler();
50+
final interceptor = AuthInterceptor(
51+
tokenGetter: () => 'my_access_token',
52+
tokenRefresher: () async => null,
53+
onLogout: () async {},
54+
);
55+
56+
final options = RequestOptions(path: '/api/v1/wellness');
57+
interceptor.onRequest(options, handler);
58+
59+
expect(
60+
options.headers['Authorization'],
61+
'Bearer my_access_token',
62+
);
63+
verify(() => handler.next(options)).called(1);
64+
});
65+
66+
test('does not add Authorization header when token is empty', () {
67+
final handler = _MockRequestInterceptorHandler();
68+
final interceptor = AuthInterceptor(
69+
tokenGetter: () => '',
70+
tokenRefresher: () async => null,
71+
onLogout: () async {},
72+
);
73+
74+
final options = RequestOptions(path: '/api/v1/wellness');
75+
interceptor.onRequest(options, handler);
76+
77+
expect(options.headers.containsKey('Authorization'), isFalse);
78+
verify(() => handler.next(options)).called(1);
79+
});
80+
});
81+
82+
group('onError — non-401', () {
83+
test('passes through errors that are not 401', () async {
84+
final handler = _MockErrorInterceptorHandler();
85+
final interceptor = AuthInterceptor(
86+
tokenGetter: () => 'token',
87+
tokenRefresher: () async => null,
88+
onLogout: () async {},
89+
);
90+
91+
final options = RequestOptions(path: '/api/v1/wellness');
92+
final err = _buildDioError(options: options, statusCode: 500);
93+
94+
await interceptor.onError(err, handler);
95+
96+
verify(() => handler.next(err)).called(1);
97+
});
98+
99+
test('passes through errors with null status code', () async {
100+
final handler = _MockErrorInterceptorHandler();
101+
final interceptor = AuthInterceptor(
102+
tokenGetter: () => 'token',
103+
tokenRefresher: () async => null,
104+
onLogout: () async {},
105+
);
106+
107+
final options = RequestOptions(path: '/api/v1/wellness');
108+
final err = _buildDioError(options: options);
109+
110+
await interceptor.onError(err, handler);
111+
112+
verify(() => handler.next(err)).called(1);
113+
});
114+
});
115+
116+
group('onError — 401 on auth endpoints (skip refresh)', () {
117+
test('skips refresh for /auth/login endpoint', () async {
118+
final handler = _MockErrorInterceptorHandler();
119+
var refreshCalled = false;
120+
final interceptor = AuthInterceptor(
121+
tokenGetter: () => 'token',
122+
tokenRefresher: () async {
123+
refreshCalled = true;
124+
return 'new_token';
125+
},
126+
onLogout: () async {},
127+
);
128+
129+
final options = RequestOptions(path: '/api/v1/auth/login');
130+
final err = _buildDioError(options: options, statusCode: 401);
131+
132+
await interceptor.onError(err, handler);
133+
134+
expect(refreshCalled, isFalse);
135+
verify(() => handler.next(err)).called(1);
136+
});
137+
138+
test('skips refresh for /auth/refresh endpoint', () async {
139+
final handler = _MockErrorInterceptorHandler();
140+
var refreshCalled = false;
141+
final interceptor = AuthInterceptor(
142+
tokenGetter: () => 'token',
143+
tokenRefresher: () async {
144+
refreshCalled = true;
145+
return 'new_token';
146+
},
147+
onLogout: () async {},
148+
);
149+
150+
final options = RequestOptions(path: '/api/v1/auth/refresh');
151+
final err = _buildDioError(options: options, statusCode: 401);
152+
153+
await interceptor.onError(err, handler);
154+
155+
expect(refreshCalled, isFalse);
156+
verify(() => handler.next(err)).called(1);
157+
});
158+
});
159+
160+
group('onError — 401 with null/empty new token', () {
161+
test(
162+
'calls onLogout and passes error when refresher returns null',
163+
() async {
164+
final handler = _MockErrorInterceptorHandler();
165+
var logoutCalled = false;
166+
final interceptor = AuthInterceptor(
167+
tokenGetter: () => 'old_token',
168+
tokenRefresher: () async => null,
169+
onLogout: () async {
170+
logoutCalled = true;
171+
},
172+
);
173+
174+
final options = RequestOptions(path: '/api/v1/wellness');
175+
final err = _buildDioError(options: options, statusCode: 401);
176+
177+
await interceptor.onError(err, handler);
178+
179+
expect(logoutCalled, isTrue);
180+
verify(() => handler.next(err)).called(1);
181+
},
182+
);
183+
184+
test(
185+
'calls onLogout and passes error when refresher returns empty string',
186+
() async {
187+
final handler = _MockErrorInterceptorHandler();
188+
var logoutCalled = false;
189+
final interceptor = AuthInterceptor(
190+
tokenGetter: () => 'old_token',
191+
tokenRefresher: () async => '',
192+
onLogout: () async {
193+
logoutCalled = true;
194+
},
195+
);
196+
197+
final options = RequestOptions(path: '/api/v1/wellness');
198+
final err = _buildDioError(options: options, statusCode: 401);
199+
200+
await interceptor.onError(err, handler);
201+
202+
expect(logoutCalled, isTrue);
203+
verify(() => handler.next(err)).called(1);
204+
},
205+
);
206+
});
207+
208+
group('onError — 401 with refresher exception', () {
209+
test(
210+
'calls onLogout when refresher throws a generic exception',
211+
() async {
212+
final handler = _MockErrorInterceptorHandler();
213+
var logoutCalled = false;
214+
final interceptor = AuthInterceptor(
215+
tokenGetter: () => 'old_token',
216+
tokenRefresher: () async {
217+
throw Exception('network failure');
218+
},
219+
onLogout: () async {
220+
logoutCalled = true;
221+
},
222+
);
223+
224+
final options = RequestOptions(path: '/api/v1/wellness');
225+
final err = _buildDioError(options: options, statusCode: 401);
226+
227+
await interceptor.onError(err, handler);
228+
229+
expect(logoutCalled, isTrue);
230+
verify(() => handler.next(err)).called(1);
231+
},
232+
);
233+
});
234+
});
235+
}

0 commit comments

Comments
 (0)