feat(flutter): 새 메일 알림(FCM) · 최소 판 확인 · 설정 탭

앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다.

- firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다.
  google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다).
- 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다.
- 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다
  (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다).
- 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다.
- 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다).
- 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다.
- 시험 932 → 1,056.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 00:46:11 +09:00
parent bf725ae96d
commit e03eda1fd3
74 changed files with 7067 additions and 229 deletions
+30 -4
View File
@@ -14,6 +14,7 @@ import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -159,7 +160,7 @@ void main() {
await tester.tap(tab('설정'));
await tester.pumpAndSettle();
expect(page(SettingsTexts.home), findsOneWidget);
expect(find.byType(SettingsPage), findsOneWidget);
await tester.tap(tab('메일'));
await tester.pumpAndSettle();
@@ -216,7 +217,7 @@ void main() {
}
});
testWidgets('설정 첫 화면의 절과 [로그아웃] — 누르면 로그인 화면으로', (
testWidgets('설정은 한 화면 — 절과 [로그아웃], 확인 창에서 [로그아웃]을 고르면 로그인 화면으로', (
WidgetTester tester,
) async {
final GoRouter router = await pumpSignedIn(tester);
@@ -224,22 +225,47 @@ void main() {
await tester.tap(tab('설정'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, SettingsTexts.home), findsOneWidget);
for (final String item in <String>[
SettingsTexts.account,
SettingsTexts.notifications,
SettingsTexts.about,
SettingsTexts.support,
SettingsTexts.signOut,
]) {
expect(page(item), findsOneWidget, reason: item);
}
await tester.tap(page(SettingsTexts.signOut));
// [로그아웃]은 목록 끝이다 — 화면 밖이면 아직 그리지 않았으니 끌어 올려 찾는다
final Finder signOut = find.widgetWithText(
OutlinedButton,
SettingsTexts.signOut,
);
await tester.scrollUntilVisible(
signOut,
200,
scrollable: find.descendant(
of: find.byType(SettingsPage),
matching: find.byType(Scrollable),
),
);
await tester.pumpAndSettle();
await tester.tap(signOut);
await tester.pumpAndSettle();
expect(find.text(SettingsTexts.signOutTitle), findsOneWidget);
expect(h.server.received('POST', ApiPaths.appLogout), isEmpty);
await tester.tap(
find.descendant(
of: find.byType(AlertDialog),
matching: find.text(SettingsTexts.signOut),
),
);
await tester.pumpAndSettle();
expect(find.byType(LoginPage), findsOneWidget);
expect(router.routerDelegate.currentConfiguration.uri.path, '/login');
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
expect(h.push.unregistered, 1);
});
});
@@ -0,0 +1,372 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/app_config/app_config.dart';
import 'package:arasystem_app/core/app_config/app_config_controller.dart';
import 'package:arasystem_app/core/app_config/app_config_repository.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/provider_harness.dart';
import '../../support/push_fakes.dart';
/// 앱 설정 · 최소 판 확인 — `GET /api/app-config` (계약 1절 "앱 새 메일 알림 · 앱 설정")
void main() {
group('판 읽기 · 견주기', () {
test('숫자 셋 — 모자란 칸은 0, `-` · `+` 뒤는 버린다', () {
expect(AppVersion.tryParse('1.2.3'), const AppVersion(1, 2, 3));
expect(AppVersion.tryParse('1.2'), const AppVersion(1, 2, 0));
expect(AppVersion.tryParse('3'), const AppVersion(3, 0, 0));
expect(AppVersion.tryParse(' 1.2.3 '), const AppVersion(1, 2, 3));
expect(AppVersion.tryParse('1.2.3-beta.1'), const AppVersion(1, 2, 3));
expect(AppVersion.tryParse('1.2.3+7'), const AppVersion(1, 2, 3));
expect(AppVersion.tryParse('1.0.0-rc+4'), const AppVersion(1, 0, 0));
expect(AppVersion.tryParse('1.2.3')?.toString(), '1.2.3');
expect(
AppVersion.tryParse('9999.0.9999'),
const AppVersion(9999, 0, 9999),
reason: '칸마다 4자리까지 — 서버의 판 칸과 같다',
);
});
test('읽지 못하는 판은 null — 칸 넷 · 숫자 아님 · 빈 칸 · 다섯 자리 칸', () {
for (final String? text in <String?>[
null,
'',
'a.b.c',
'1.2.3.4',
'1..2',
'1.-2.0',
'.1.2',
'1.2.',
'12345.0.0',
'1.2.10000',
'v1.2.3',
'-1.0.0',
]) {
expect(AppVersion.tryParse(text), isNull, reason: '$text');
}
});
test('칸마다 숫자로 견준다 — 1.10.0 은 1.9.9 보다 높다', () {
expect(const AppVersion(1, 9, 9) < const AppVersion(1, 10, 0), isTrue);
expect(const AppVersion(1, 99, 99) < const AppVersion(2, 0, 0), isTrue);
expect(const AppVersion(1, 2, 3) < const AppVersion(1, 2, 3), isFalse);
expect(
const AppVersion(1, 2, 4).compareTo(const AppVersion(1, 2, 3)),
greaterThan(0),
);
});
test('업데이트해야 하는 때는 이 앱이 최소 판보다 낮을 때뿐 — 어느 쪽이든 읽지 못하면 막지 않는다', () {
const AppVersion minimum = AppVersion(1, 0, 1);
expect(
isAppUpdateRequired(appVersion: '1.0.0', minimum: minimum),
isTrue,
);
expect(
isAppUpdateRequired(appVersion: '1.0.1', minimum: minimum),
isFalse,
);
expect(isAppUpdateRequired(appVersion: '1.1', minimum: minimum), isFalse);
expect(isAppUpdateRequired(appVersion: null, minimum: minimum), isFalse);
expect(isAppUpdateRequired(appVersion: 'dev', minimum: minimum), isFalse);
expect(isAppUpdateRequired(appVersion: '0.9.0', minimum: null), isFalse);
});
test('서버에 알리는 판은 숫자 셋 꼴로 맞춘다', () {
expect(normalizedAppVersion('1.2'), '1.2.0');
expect(normalizedAppVersion('1.0.0-beta'), '1.0.0');
expect(normalizedAppVersion('dev'), isNull);
expect(normalizedAppVersion(null), isNull);
});
});
group('응답 읽기 (AppConfig.fromJson)', () {
test('안드로이드의 최소 판 · 알림을 쓸 수 있는지', () {
expect(
AppConfig.fromJson(const <String, Object?>{
'minSupportedVersion': <String, Object?>{
'android': '1.2.0',
'ios': null,
},
'pushAvailable': true,
}),
const AppConfig(
minSupportedVersion: AppVersion(1, 2, 0),
pushAvailable: true,
),
);
});
test('모양이 틀린 칸은 없는 것으로 본다(막지 않는다 · 알림은 모른다)', () {
expect(AppConfig.fromJson(null), const AppConfig());
expect(AppConfig.fromJson('1.0.0'), const AppConfig());
expect(
AppConfig.fromJson(const <String, Object?>{
'minSupportedVersion': '1.0.0',
'pushAvailable': 'yes',
}),
const AppConfig(),
);
expect(
AppConfig.fromJson(const <String, Object?>{
'minSupportedVersion': <String, Object?>{'android': 'latest'},
'pushAvailable': false,
}),
const AppConfig(pushAvailable: false),
);
expect(
AppConfig.fromJson(const <String, Object?>{
'minSupportedVersion': <String, Object?>{'ios': '2.0.0'},
}),
const AppConfig(),
reason: '다른 플랫폼의 판은 보지 않는다',
);
});
});
group('저장소 — 로그인 없음 · 알림 끔', () {
late ApiHarness api;
setUp(() => api = ApiHarness());
test('받은 값을 읽는다 — Bearer 를 붙이지 않고 알리지 않는다', () async {
await api.prepare();
await api.signIn();
api.server.on(
'GET',
ApiPaths.appConfig,
(_) => FakeResponse.ok(<String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
'pushAvailable': true,
}),
);
final AppConfig config = await AppConfigRepository(api.dio).fetch();
expect(config.pushAvailable, isTrue);
final RequestOptions sent = api.server.requests.single;
expect(bearerTokenOf(sent), isNull);
expect(sent.extra[kSilentExtraKey], isTrue);
});
test('앱의 저장소는 인증 줄 밖으로 보낸다 — 앞 요청의 미리 갱신을 기다리지 않는다', () async {
// 앱을 막 띄웠다 — 보호 요청(me)이 보내기 전에 갱신하는데, 갱신이 아직 답하지 않는다
final ProviderHarness h = ProviderHarness();
await h.prepare(refreshToken: 'refresh-1');
final Completer<void> refreshGate = Completer<void>();
h.server
..on('POST', ApiPaths.appRefresh, (_) async {
await refreshGate.future;
return FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': tokensJson(2, now: h.clock.now),
});
})
..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()))
..on(
'GET',
ApiPaths.appConfig,
(_) => FakeResponse.ok(<String, Object?>{'pushAvailable': true}),
);
final ProviderContainer container = h.container();
addTearDown(container.dispose);
final Future<Response<Object?>> me = container
.read(dioProvider)
.get<Object?>(ApiPaths.me);
await pumpEventQueue();
expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1));
final AppConfig config = await container
.read(appConfigRepositoryProvider)
.fetch();
expect(config.pushAvailable, isTrue);
expect(h.server.received('GET', ApiPaths.me), isEmpty, reason: '갱신 전');
refreshGate.complete();
await me;
});
test('앱을 막 띄웠을 때(리프레시 토큰만) 미리 갱신하지 않는다', () async {
await api.prepare();
await api.coldStartWithRefreshToken('refresh-1');
api.server.on(
'GET',
ApiPaths.appConfig,
(_) => FakeResponse.ok(<String, Object?>{'pushAvailable': true}),
);
await AppConfigRepository(api.dio).fetch();
expect(api.server.received('POST', ApiPaths.appRefresh), isEmpty);
expect(api.server.requests, hasLength(1));
});
test(
'그 경로가 없는 옛 서버(404 RES_001)는 받은 것으로 본다 — 최소 판 없음 · 알림 쓸 수 없음',
() async {
await api.prepare();
api.server.on(
'GET',
ApiPaths.appConfig,
(_) => FakeResponse.fail(404, 'RES_001'),
);
expect(
await AppConfigRepository(api.dio).fetch(),
AppConfig.notProvided,
);
expect(api.notifier.shownCodes, isEmpty);
},
);
test('그 밖의 실패는 DioException 으로 던지고 알리지 않는다', () async {
await api.prepare();
api.server.on(
'GET',
ApiPaths.appConfig,
(_) => FakeResponse.fail(503, 'SYS_001'),
);
expect(
await errorOf(AppConfigRepository(api.dio).fetch()),
isA<DioException>(),
);
expect(api.notifier.shownCodes, isEmpty);
});
});
group('컨트롤러 — 띄울 때 한 번 · 앞으로 올 때 1시간 · 실패는 1분', () {
late ProviderHarness h;
late ProviderContainer container;
late int requests;
late FakeResponse Function() respond;
setUp(() async {
h = ProviderHarness();
requests = 0;
respond = () => FakeResponse.ok(<String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
'pushAvailable': true,
});
h.server.on('GET', ApiPaths.appConfig, (_) {
requests++;
return respond();
});
await h.prepare();
container = ProviderContainer(
overrides: <Override>[
...h.overrides,
appForegroundProvider.overrideWith(FakeForeground.new),
appVersionProvider.overrideWithValue('1.0.0'),
],
retry: noAutomaticRetry,
);
addTearDown(container.dispose);
container
..listen(appConfigProvider, (_, _) {})
..listen(appUpdateRequiredProvider, (_, _) {})
..listen(pushAvailableProvider, (_, _) {});
});
void comeBack() {
final FakeForeground foreground =
container.read(appForegroundProvider.notifier) as FakeForeground;
foreground
..set(false)
..set(true);
}
Future<void> idle() async {
for (int turn = 0; turn < 40; turn++) {
await Future<void>.delayed(Duration.zero);
}
}
test('만들자마자 한 번 묻고, 앞으로 와도 1시간 안이면 다시 묻지 않는다', () async {
await pumpEventsUntil(
() => container.read(appConfigProvider).fetchedAt != null,
);
expect(requests, 1);
expect(container.read(pushAvailableProvider), isTrue);
expect(container.read(appUpdateRequiredProvider), isFalse);
h.clock.advance(const Duration(minutes: 59));
comeBack();
await idle();
expect(requests, 1);
h.clock.advance(const Duration(minutes: 1));
comeBack();
await pumpEventsUntil(() => requests == 2);
});
test('최소 판이 이 앱보다 높으면 업데이트해야 하고, 내리면 다음 확인에서 풀린다', () async {
respond = () => FakeResponse.ok(<String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '1.0.1'},
'pushAvailable': true,
});
await pumpEventsUntil(() => container.read(appUpdateRequiredProvider));
respond = () => FakeResponse.ok(<String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
'pushAvailable': true,
});
h.clock.advance(const Duration(hours: 1));
comeBack();
await pumpEventsUntil(() => !container.read(appUpdateRequiredProvider));
});
test('받지 못하면 막지 않고 알리지 않는다 — 앞으로 올 때 1분 간격으로 다시 묻는다', () async {
respond = () => FakeResponse.offline;
await pumpEventsUntil(
() => container.read(appConfigProvider).failedAt != null,
);
expect(container.read(appUpdateRequiredProvider), isFalse);
expect(container.read(pushAvailableProvider), isNull);
expect(h.notifier.shownCodes, isEmpty);
h.clock.advance(const Duration(seconds: 59));
comeBack();
await idle();
expect(requests, 1);
respond = () =>
FakeResponse.ok(<String, Object?>{'pushAvailable': false});
h.clock.advance(const Duration(seconds: 1));
comeBack();
await pumpEventsUntil(
() => container.read(pushAvailableProvider) == false,
);
expect(requests, 2);
});
test('받은 뒤의 실패는 받은 값을 그대로 둔다', () async {
await pumpEventsUntil(
() => container.read(appConfigProvider).fetchedAt != null,
);
respond = () => FakeResponse.fail(503, 'SYS_001');
await container.read(appConfigProvider.notifier).refresh();
expect(container.read(pushAvailableProvider), isTrue);
expect(container.read(appConfigProvider).failedAt, isNotNull);
});
test('기기 시계를 뒤로 돌렸으면 지난 시간을 모르니 묻는다', () async {
await pumpEventsUntil(() => requests == 1);
await idle();
h.clock.advance(const Duration(minutes: -10));
comeBack();
await pumpEventsUntil(() => requests == 2);
});
});
}
+349
View File
@@ -0,0 +1,349 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/app_config/app_config_controller.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_events.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/router/app_routes.dart';
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_fit.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/compose_fakes.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
import '../../support/mail_fakes.dart';
import '../../support/mail_harness.dart';
import '../../support/push_fakes.dart';
/// 앱 전체에서 본 새 메일 알림 — 누른 알림의 이동 · 쓰기 화면이 내려갈 때 마저 저장 · 앞에서 받은 알림의 새 메일 확인
void main() {
late MailAppHarness h;
late FakeComposeBackend c;
late FakePushMessaging messaging;
Future<ComposeQuote> inlineQuote(ComposeQuoteRequest request) async =>
initialQuoteOf(request);
List<Override> pushOverrides(FakePushMessaging fake) => <Override>[
pushMessagingProvider.overrideWithValue(fake),
notificationSystemProvider.overrideWithValue(FakeNotificationSystem()),
pushPreferencesProvider.overrideWithValue(MemoryPushPreferences()),
];
setUp(() {
h = MailAppHarness();
c = FakeComposeBackend(h.server, h.mail);
messaging = FakePushMessaging()..current = PushPermission.granted;
h.overrides.addAll(<Override>[
composeQuoteBuilderProvider.overrideWithValue(inlineQuote),
...pushOverrides(messaging),
]);
h.server.serverTime = kTestNow;
});
Future<void> start(WidgetTester tester, {String? location}) async {
c.install();
h.mail.seed(kInboxId, 2);
await h.pump(tester, location: location);
}
Finder snack(String text) =>
find.descendant(of: find.byType(SnackBar), matching: find.text(text));
group('누른 알림 — 늘 메일 탭 받은편지함', () {
testWidgets('다른 탭(설정)에 있다가 누르면 받은편지함으로 간다', (WidgetTester tester) async {
await start(tester);
await tester.tap(find.text(SettingsTexts.home).last);
await tester.pumpAndSettle();
expect(h.location(), '/settings');
messaging.tap(kNewMailPush);
await tester.pumpAndSettle();
expect(h.location(), MailRoutes.home);
expect(find.byType(MailListPage), findsOneWidget);
});
testWidgets('읽기 화면에 있다가 누르면 읽기 화면을 닫고 받은편지함으로 간다', (
WidgetTester tester,
) async {
await start(tester);
unawaited(h.router.push<void>(MailRoutes.message('m1')));
await tester.pumpAndSettle();
expect(find.byType(MessageReaderPage), findsOneWidget);
messaging.tap(kNewMailPush);
await tester.pumpAndSettle();
expect(find.byType(MessageReaderPage), findsNothing);
expect(h.location(), MailRoutes.home);
});
testWidgets('꺼져 있다가 뜬 알림은 세션을 되살린 뒤 받은편지함 — 한 번만 가져간다', (
WidgetTester tester,
) async {
messaging.initial = kNewMailPush;
await start(tester);
expect(h.location(), MailRoutes.home);
expect(
h.container.read(pushEventsProvider.notifier).takeOpened(),
isNull,
reason: '라우터가 가져갔다',
);
});
testWidgets('로그아웃 상태면 누른 알림은 로그인 화면에 머문다', (WidgetTester tester) async {
final LoginHarness login = LoginHarness();
final FakePushMessaging fake = FakePushMessaging();
await login.pump(tester, overrides: pushOverrides(fake));
expect(find.byType(LoginPage), findsOneWidget);
fake.tap(kNewMailPush);
await tester.pumpAndSettle();
expect(find.byType(LoginPage), findsOneWidget);
});
});
group('쓰기 화면이 알림으로 내려가면 임시보관함에 마저 저장한다', () {
testWidgets('바꾼 것이 있으면 쓰다 만 주소까지 저장하고 "임시보관함에 저장했습니다."', (
WidgetTester tester,
) async {
await start(tester);
unawaited(h.router.push<void>(MailRoutes.compose));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey<String>('compose-body')),
'알림을 누르기 전에 쓰던 글',
);
await tester.pump();
messaging.tap(kNewMailPush);
await tester.pumpAndSettle();
// 저장은 화면이 내려간 뒤 뒤에서 이어진다
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(find.byType(MessageComposePage), findsNothing);
expect(h.location(), MailRoutes.home);
expect(c.saves.single.call, 'POST');
expect(c.saves.single.body['body'], contains('알림을 누르기 전에 쓰던 글'));
expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget);
});
testWidgets('저장하지 못하면 그 코드의 사전 문구로 알린다', (WidgetTester tester) async {
await start(tester);
unawaited(h.router.push<void>(MailRoutes.compose));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey<String>('compose-body')),
'저장이 실패할 글',
);
await tester.pump();
c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001'));
messaging.tap(kNewMailPush);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(find.byType(MessageComposePage), findsNothing);
expect(c.saves, hasLength(1));
expect(h.notifier.shownCodes, <String>['MAIL_001']);
expect(snack(MailComposeTexts.savedOnLeave), findsNothing);
});
testWidgets('세션에 제한이 생겨(갱신 응답) 제한 단계로 옮겨 내려가면 알리지 않는다', (
WidgetTester tester,
) async {
await start(tester);
unawaited(h.router.push<void>(MailRoutes.compose));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey<String>('compose-body')),
'제한 전에 쓰던 글',
);
await tester.pump();
// 서버는 제한된 세션의 임시 저장을 막는다(403 AUTH_010) — me 도 제한을 말한다
c.draftFailures
..add(FakeResponse.fail(403, 'AUTH_010'))
..add(FakeResponse.fail(403, 'AUTH_010'));
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')),
);
h.container
.read(sessionEventsProvider)
.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange')));
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(h.location(), AppRoutes.passwordChange);
expect(find.byType(MessageComposePage), findsNothing);
expect(h.notifier.shownCodes, isNot(contains('AUTH_010')));
expect(snack(MailComposeTexts.savedOnLeave), findsNothing);
});
testWidgets('바꾼 것이 없고 저장한 적도 없으면 조용히 내려간다', (WidgetTester tester) async {
await start(tester);
unawaited(h.router.push<void>(MailRoutes.compose));
await tester.pumpAndSettle();
messaging.tap(kNewMailPush);
await tester.pumpAndSettle();
expect(find.byType(MessageComposePage), findsNothing);
expect(c.saves, isEmpty);
expect(find.byType(SnackBar), findsNothing);
expect(h.notifier.shownCodes, isEmpty);
});
});
group('업데이트 화면 (최소 판)', () {
testWidgets('이 앱이 최소 판보다 낮아지면 어느 화면에서든 업데이트 화면 — 쓰던 메일은 저장하고, 풀리면 돌아온다', (
WidgetTester tester,
) async {
String minimum = '1.0.0';
h.overrides.add(appVersionProvider.overrideWithValue('1.0.0'));
h.server.on(
'GET',
ApiPaths.appConfig,
(_) => FakeResponse.ok(<String, Object?>{
'minSupportedVersion': <String, Object?>{'android': minimum},
'pushAvailable': true,
}),
);
await start(tester);
expect(h.location(), MailRoutes.home);
unawaited(h.router.push<void>(MailRoutes.compose));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey<String>('compose-body')),
'업데이트 전에 쓰던 글',
);
await tester.pump();
minimum = '1.1.0';
unawaited(h.container.read(appConfigProvider.notifier).refresh());
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(h.location(), '/update');
expect(find.text(ScreenTitles.update), findsOneWidget);
expect(find.byType(MessageComposePage), findsNothing);
expect(c.saves.single.call, 'POST');
expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget);
// 업데이트 화면에서는 누른 알림도 업데이트 화면으로 돌아온다
messaging.tap(kNewMailPush);
await tester.pumpAndSettle();
expect(h.location(), '/update');
minimum = '1.0.0';
unawaited(h.container.read(appConfigProvider.notifier).refresh());
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(h.location(), MailRoutes.home);
});
testWidgets('로그인 전 화면에서도 업데이트 화면이다', (WidgetTester tester) async {
final LoginHarness login = LoginHarness();
login.server.on(
'GET',
ApiPaths.appConfig,
(_) => FakeResponse.ok(<String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '2.0.0'},
'pushAvailable': true,
}),
);
await login.pump(
tester,
overrides: <Override>[appVersionProvider.overrideWithValue('1.0.0')],
);
expect(find.text(ScreenTitles.update), findsOneWidget);
expect(find.byType(LoginPage), findsNothing);
});
});
group('앞에 있을 때 온 새 메일 알림', () {
testWidgets('목록이 보이면 10초 간격을 건너뛰고 바로 새 메일을 확인한다', (
WidgetTester tester,
) async {
await start(tester);
final int asked = h.mail.stateRequests;
messaging.receive(kNewMailPush);
await tester.pumpAndSettle();
expect(h.mail.stateRequests, asked + 1);
// 세션 끝 알림은 새 메일이 아니다
messaging.receive(kSessionEndedPush);
await tester.pumpAndSettle();
expect(h.mail.stateRequests, asked + 1);
// 1분 차례는 그대로 이어 간다
h.clock.advance(kMailStatePollInterval);
await tester.pump(kMailStatePollInterval);
await tester.pumpAndSettle();
expect(h.mail.stateRequests, asked + 2);
});
testWidgets('묻는 중에 오면 그 물음이 끝난 뒤 한 번 더 묻는다', (WidgetTester tester) async {
await start(tester);
final int asked = h.mail.stateRequests;
final Completer<void> gate = Completer<void>();
h.mail.stateGate = gate;
h.clock.advance(kMailStatePollInterval);
await tester.pump(kMailStatePollInterval);
expect(h.mail.stateRequests, asked + 1, reason: '1분 차례의 물음이 서버에 닿았다');
messaging.receive(kNewMailPush);
await tester.pump();
expect(h.mail.stateRequests, asked + 1, reason: '묻는 중에는 겹쳐 묻지 않는다');
gate.complete();
await tester.pumpAndSettle();
expect(h.mail.stateRequests, asked + 2);
});
testWidgets('목록이 안 보이면(설정 탭) 묻지 않고, 돌아오면 지금 규칙대로', (
WidgetTester tester,
) async {
await start(tester);
await tester.tap(find.text(SettingsTexts.home).last);
await tester.pumpAndSettle();
final int asked = h.mail.stateRequests;
messaging.receive(kNewMailPush);
await tester.pumpAndSettle();
expect(h.mail.stateRequests, asked);
});
});
}
@@ -0,0 +1,744 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_controller.dart';
import 'package:arasystem_app/core/push/push_events.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_state.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/push_fakes.dart';
/// 새 메일 알림 컨트롤러 — 진짜 세션 노티파이어와 가짜 FCM · 알림창 · 서버로 흐름을 본다(계약 1절 "앱 새 메일 알림 · 앱 설정" · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘")
void main() {
late PushHarness h;
late ProviderContainer container;
/// 앱을 띄운다 — [refreshToken] 이 있으면 세션을 되살린다(갱신 → me → 등록)
Future<void> start({
String? refreshToken = 'refresh-1',
PushPermission permission = PushPermission.granted,
}) async {
h.messaging.current = permission;
await h.prepare(refreshToken: refreshToken);
container = h.container();
addTearDown(container.dispose);
container
..listen(sessionProvider, (_, _) {})
..listen(pushControllerProvider, (_, _) {})
..listen(pushEventsProvider, (_, _) {});
}
PushState push() => container.read(pushControllerProvider);
PushController controller() =>
container.read(pushControllerProvider.notifier);
/// 이벤트 차례를 넉넉히 넘긴다 — "보내지 않았다"를 볼 때
Future<void> idle() async {
for (int turn = 0; turn < 60; turn++) {
await Future<void>.delayed(Duration.zero);
}
}
Future<void> until(bool Function() condition, String reason) =>
pumpEventsUntil(condition, reason: reason);
Future<void> signedIn() async {
await until(
() => container.read(sessionProvider).value is SignedIn,
'세션 되살리기',
);
}
/// 앱이 뒤로 갔다가 앞으로 온다
void comeBack() {
final FakeForeground foreground =
container.read(appForegroundProvider.notifier) as FakeForeground;
foreground
..set(false)
..set(true);
}
setUp(() => h = PushHarness());
group('등록 (PUT — FID 를 받았을 때 · 바뀌었을 때 · 세션이 열릴 때 한 번)', () {
test('세션을 되살리고 켬 · 허락이면 FCM 에 등록하고 받은 FID 로 PUT 한다', () async {
await start();
await signedIn();
await until(() => h.push.puts.isNotEmpty, '등록');
expect(h.push.puts.single.body, <String, Object?>{
'fid': 'fid-1',
'platform': 'android',
'appVersion': '1.0.0',
});
expect(h.push.puts.single.bearer, 'access-2');
expect(h.messaging.registerCalls, 1);
await until(() => push().registered, '등록 상태');
expect(h.prefs.values.lastFid, 'fid-1');
expect(h.prefs.values.serverRegistered, isTrue);
// 저절로 하는 등록은 알리지 않는다
final RequestOptions sent = h.server
.received('PUT', ApiPaths.pushRegistration)
.single;
expect(sent.extra[kSilentExtraKey], isTrue);
});
test('FID 가 바뀌면 다시 PUT 하고, 같은 FID 가 다시 오면 보내지 않는다', () async {
await start();
await until(() => h.push.puts.isNotEmpty, '첫 등록');
h.messaging.emitFid('fid-1');
await idle();
expect(h.push.puts, hasLength(1));
h.messaging.emitFid('fid-2');
await until(() => h.push.puts.length == 2, 'FID 바뀜');
expect(h.push.puts.last.body['fid'], 'fid-2');
await until(() => h.prefs.values.lastFid == 'fid-2', '마지막 FID');
});
test('지난 실행에 올린 FID 가 있으면 등록 소식을 기다리지 않고 바로 PUT 한다(앱을 띄울 때 한 번)', () async {
h.prefs.values = const PushPreferenceValues(
lastFid: 'fid-old',
serverRegistered: true,
);
h.messaging.fidOnRegister = null;
await start();
await until(() => h.push.puts.isNotEmpty, '등록');
expect(h.push.puts.single.body['fid'], 'fid-old');
// FCM 등록은 그대로 한 번 한다(FID 가 바뀌었으면 그 소식이 다시 PUT 한다)
expect(h.messaging.registerCalls, 1);
});
test('앞으로 올 때마다 PUT 하지 않는다', () async {
await start();
await until(() => h.push.puts.isNotEmpty, '등록');
comeBack();
await idle();
comeBack();
await idle();
expect(h.push.puts, hasLength(1));
});
test('끈 휴대폰 · 허락 전 · 채널 꺼짐이면 FCM 에도 서버에도 등록하지 않는다', () async {
Future<void> expectNoRegistration(
PushPermission permission,
void Function() arrange,
) async {
h = PushHarness();
arrange();
await start(permission: permission);
await signedIn();
await idle();
expect(h.push.puts, isEmpty);
expect(h.push.deletes, isEmpty, reason: '서버 줄이 없다고 기억한다');
expect(h.messaging.registerCalls, 0);
}
await expectNoRegistration(
PushPermission.granted,
() => h.prefs.values = const PushPreferenceValues(enabled: false),
);
await expectNoRegistration(PushPermission.notAsked, () {});
await expectNoRegistration(PushPermission.denied, () {});
await expectNoRegistration(PushPermission.granted, () {
h.notifications.current = const NotificationStatus(
appEnabled: true,
channelEnabled: false,
sdkInt: 34,
);
});
});
test(
'서버가 보낼 수 없다면(pushAvailable false · 그 경로가 없는 옛 서버) 로그인해도 등록하지 않는다',
() async {
Future<void> expectUnavailable(void Function() arrange) async {
h = PushHarness();
arrange();
await start(refreshToken: null);
await until(() => push().server == PushServer.unavailable, '앱 설정 받기');
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.ok(<String, Object?>{
'status': 'signedIn',
...sessionJson(),
'tokens': tokensJson(1, now: h.clock.now),
}),
);
await container
.read(sessionProvider.notifier)
.signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
await idle();
expect(h.push.puts, isEmpty);
expect(h.messaging.registerCalls, 0);
expect(pushRowStatusOf(push()), PushRowStatus.unavailable);
}
await expectUnavailable(
() => h.push.config = <String, Object?>{
'minSupportedVersion': <String, Object?>{'android': null},
'pushAvailable': false,
},
);
await expectUnavailable(
() => h.push.configResponse = FakeResponse.fail(404, 'RES_001'),
);
},
);
test('서버 줄이 있을 수 있는 휴대폰을 끈 채 띄우면 지운다(DELETE)', () async {
h.prefs.values = const PushPreferenceValues(
enabled: false,
serverRegistered: true,
);
await start();
await until(() => h.push.deletes.isNotEmpty, '지우기');
expect(h.push.puts, isEmpty);
expect(h.push.deletes.single.extra[kSilentExtraKey], isTrue);
await until(() => !h.prefs.values.serverRegistered, '서버 줄 없음');
});
test('앞으로 왔을 때 권한이 꺼졌으면 DELETE, 다시 켜졌으면 PUT', () async {
await start();
await until(() => h.push.puts.isNotEmpty, '등록');
await until(() => push().registered, '등록 상태');
h.messaging.current = PushPermission.blocked;
comeBack();
await until(() => h.push.deletes.isNotEmpty, '권한이 꺼진 것을 알았다');
await until(() => !push().registered, '등록 없음');
expect(pushRowStatusOf(push()), PushRowStatus.blocked);
h.messaging.current = PushPermission.granted;
comeBack();
await until(() => h.push.puts.length == 2, '다시 켜짐');
});
test('앞으로 왔을 때 새 메일 채널만 꺼졌으면 지운다', () async {
await start();
await until(() => push().registered, '등록');
h.notifications.current = const NotificationStatus(
appEnabled: true,
channelEnabled: false,
sdkInt: 34,
);
comeBack();
await until(() => h.push.deletes.isNotEmpty, '채널이 꺼진 것을 알았다');
expect(pushRowStatusOf(push()), PushRowStatus.channelOff);
});
test('PUT 이 실패해도 알리지 않고 다음에 앞으로 올 때 다시 한다', () async {
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
await start();
await until(() => h.push.puts.isNotEmpty, '첫 등록');
await idle();
expect(push().registered, isFalse);
expect(h.notifier.shownCodes, isEmpty);
h.push.putFailure = null;
comeBack();
await until(() => h.push.puts.length == 2, '다시 등록');
await until(() => push().registered, '등록 상태');
});
});
group('끝나는 때 (로그아웃 · 세션 끝 · 제한)', () {
test(
'로그아웃 — 토큰을 지운 뒤 알림창을 지우고 FCM 등록을 푼다. 알림 서버의 DELETE 는 부르지 않는다',
() async {
await start();
await until(() => push().registered, '등록');
bool? hadSession;
h.messaging.onUnregister = () => hadSession = h.store.hasSession;
final int clearsBefore = h.notifications.clears;
await container.read(sessionProvider.notifier).signOut();
await until(() => h.messaging.unregisterCalls == 1, 'FCM 등록 풀기');
expect(hadSession, isFalse);
expect(h.notifications.clears, greaterThan(clearsBefore));
expect(h.push.deletes, isEmpty);
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
await until(() => !h.prefs.values.pendingUnregister, '풀기 표시 지움');
expect(h.prefs.values.lastFid, isNull);
expect(h.prefs.values.serverRegistered, isFalse);
expect(push().registered, isFalse);
expect(push().sessionReady, isFalse);
},
);
test('세션이 끝나면(AUTH_002) 알림창을 지우고 FCM 등록을 푼다', () async {
await start();
await until(() => push().registered, '등록');
final int clearsBefore = h.notifications.clears;
await h.store.clear();
container.read(sessionEventsProvider).emit(const SessionEnded());
await until(() => h.messaging.unregisterCalls == 1, 'FCM 등록 풀기');
expect(h.notifications.clears, greaterThan(clearsBefore));
expect(h.push.deletes, isEmpty);
expect(
container.read(sessionProvider).value,
const SignedOut(reason: SignOutReason.expired),
);
});
test('FCM 등록을 풀지 못하면 표시를 남기고, 로그아웃 상태로 다음에 앞으로 올 때 다시 푼다', () async {
await start();
await until(() => push().registered, '등록');
h.messaging.unregisterError = PlatformException(code: 'test');
await container.read(sessionProvider.notifier).signOut();
await until(() => h.messaging.unregisterCalls == 1, '첫 풀기');
await idle();
expect(h.prefs.values.pendingUnregister, isTrue);
h.messaging.unregisterError = null;
comeBack();
await until(() => h.messaging.unregisterCalls == 2, '다시 풀기');
await until(() => !h.prefs.values.pendingUnregister, '표시 지움');
});
test('풀지 못한 등록이 남은 채 로그아웃 상태로 앱을 띄우면 바로 다시 푼다', () async {
h.prefs.values = const PushPreferenceValues(
pendingUnregister: true,
lastFid: 'fid-old',
);
await start(refreshToken: null);
await until(() => h.messaging.unregisterCalls == 1, '다시 풀기');
await until(() => !h.prefs.values.pendingUnregister, '표시 지움');
expect(h.messaging.registerCalls, 0);
});
test('같은 휴대폰에 다른 직원 — 로그아웃 뒤 새 로그인은 새 등록 소식을 기다려 새 세션으로 PUT 한다', () async {
await start();
await until(() => push().registered, '앞 직원 등록');
await container.read(sessionProvider.notifier).signOut();
await until(() => h.messaging.unregisterCalls == 1, '앞 직원 풀기');
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.ok(<String, Object?>{
'status': 'signedIn',
'email': 'doyun.lee@arasystem.it.kr',
'name': '이도윤',
'restriction': null,
'twoFactorDeadline': null,
'tokens': tokensJson(3, now: h.clock.now),
}),
);
await container
.read(sessionProvider.notifier)
.signIn(email: 'doyun.lee@arasystem.it.kr', password: 'pw');
await until(() => h.push.puts.length == 2, '새 직원 등록');
expect(h.push.puts.last.bearer, 'access-3');
expect(h.messaging.calls, <String>['register', 'unregister', 'register']);
});
test('풀기가 실패해도 같은 실행의 다음 로그인은 FCM 에 다시 등록하고 새 세션으로 PUT 한다', () async {
await start();
await until(() => push().registered, '앞 직원 등록');
// 끊긴 망에서 로그아웃 — FCM 등록 풀기가 실패한다(서버 logout 도 닿지 못했을 수 있다)
h.messaging.unregisterError = PlatformException(code: 'offline');
await container.read(sessionProvider.notifier).signOut();
await until(() => h.messaging.unregisterCalls == 1, '앞 직원 풀기(실패)');
await idle();
expect(h.prefs.values.pendingUnregister, isTrue);
h.messaging.unregisterError = null;
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.ok(<String, Object?>{
'status': 'signedIn',
'email': 'doyun.lee@arasystem.it.kr',
'name': '이도윤',
'restriction': null,
'twoFactorDeadline': null,
'tokens': tokensJson(3, now: h.clock.now),
}),
);
await container
.read(sessionProvider.notifier)
.signIn(email: 'doyun.lee@arasystem.it.kr', password: 'pw');
await until(() => h.push.puts.length == 2, '새 직원 등록');
expect(h.messaging.calls, <String>['register', 'unregister', 'register']);
expect(h.push.puts.last.bearer, 'access-3');
expect(
h.push.puts.last.body['fid'],
h.push.puts.first.body['fid'],
reason: '같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다',
);
await until(() => !h.prefs.values.pendingUnregister, '이어진 등록이 표시를 지움');
});
test('등록이 끝나기 전에 로그아웃하고 풀기가 실패하면 "풀지 못한 등록" 표시가 남는다', () async {
h.messaging.registerGate = Completer<void>();
await start();
await until(() => h.messaging.registerCalls == 1, 'FCM 등록 시작');
// 등록이 끝나기 전에 로그아웃 — 풀기는 등록 뒤에 줄을 서고, 끊긴 망이라 실패한다
h.messaging.unregisterError = PlatformException(code: 'offline');
await container.read(sessionProvider.notifier).signOut();
await idle();
expect(h.prefs.values.pendingUnregister, isTrue);
h.messaging.registerGate!.complete();
await until(() => h.messaging.unregisterCalls == 1, '풀기(실패)');
await idle();
expect(
h.prefs.values.pendingUnregister,
isTrue,
reason: '끝난 등록이 표시를 지우지 않는다 — 다음 실행에 다시 푼다',
);
expect(h.push.puts, isEmpty, reason: '풀리는 등록의 FID 로 올리지 않는다');
});
test('세션에 제한이 생기면(갱신 응답) 알림 서버를 부르지 않고, 풀리면 다시 PUT 한다', () async {
await start();
await until(() => push().registered, '등록');
await until(() => h.push.seen.isNotEmpty, '앱을 띄운 뒤의 봤음');
final SessionEventBus bus = container.read(sessionEventsProvider);
bus.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange')));
await until(() => !push().sessionReady, '멈춤');
h.clock.advance(const Duration(minutes: 1));
final int seenBefore = h.push.seen.length;
comeBack();
await idle();
expect(h.push.seen, hasLength(seenBefore));
expect(h.push.puts, hasLength(1));
bus.emit(SessionRefreshed(sessionJson()));
await until(() => h.push.puts.length == 2, '제한이 풀려 다시 등록');
});
});
group('앞으로 옴 · 받은 알림 · 누른 알림', () {
test('앱을 띄우면(앞에 있음) 알림창을 지우고, 등록 뒤 봤음을 보낸다', () async {
await start();
await until(() => push().registered, '등록');
await until(() => h.push.seen.isNotEmpty, '봤음');
expect(h.notifications.clears, greaterThanOrEqualTo(1));
expect(h.push.seen.single.extra[kSilentExtraKey], isTrue);
});
test('앞으로 오면 봤음을 보낸다 — 30초가 지났으면 바로', () async {
await start();
await until(() => h.push.seen.isNotEmpty, '처음 봤음');
h.clock.advance(const Duration(seconds: 31));
comeBack();
await until(() => h.push.seen.length == 2, '앞으로 옴');
});
test('앞에 있을 때 온 새 메일 알림 — 메일 모듈에 알리고 봤음을 보낸다. 세션 끝 알림은 알리기만', () async {
await start();
await until(() => h.push.seen.isNotEmpty, '처음 봤음');
h.clock.advance(const Duration(seconds: 31));
h.messaging.receive(kNewMailPush);
await until(() => h.push.seen.length == 2, '받은 알림 봤음');
final PushEvent? event = container.read(pushEventsProvider);
expect(event?.opened, isFalse);
expect(event?.message.isNewMail, isTrue);
h.clock.advance(const Duration(seconds: 31));
h.messaging.receive(kSessionEndedPush);
await idle();
expect(h.push.seen, hasLength(2));
expect(container.read(pushEventsProvider)?.message.isNewMail, isFalse);
});
test('누른 알림은 라우터가 가져갈 때까지 값으로 든다 — 꺼져 있다가 뜬 알림 · 뒤에서 누른 알림', () async {
h.messaging.initial = kNewMailPush;
await start();
await until(
() => container.read(pushEventsProvider)?.opened ?? false,
'꺼져 있다가 뜬 알림',
);
final PushEvents events = container.read(pushEventsProvider.notifier);
expect(events.takeOpened(), kNewMailPush);
expect(events.takeOpened(), isNull);
h.messaging.tap(kNewMailPush);
await until(
() => (container.read(pushEventsProvider)?.seq ?? 0) == 2,
'뒤에서 누른 알림',
);
expect(events.takeOpened(), kNewMailPush);
});
});
group('봤음 — 30초 간격 · 건너뛰면 끝에 한 번 (시험 시계)', () {
/// 시험 시계를 넘기며 조건이 될 때까지 — 네트워크(가짜 서버)는 시험 시계 안에서 차례가 넘어가야 끝난다
Future<void> pumpUntil(
WidgetTester tester,
bool Function() condition,
String reason,
) async {
for (int turn = 0; turn < 300 && !condition(); turn++) {
await tester.pump(const Duration(milliseconds: 1));
}
expect(condition(), isTrue, reason: reason);
}
/// 손 시계와 시험 시계를 함께 넘긴다(간격은 손 시계로 재고 끝은 타이머로 기다린다)
Future<void> elapse(WidgetTester tester, Duration duration) async {
h.clock.advance(duration);
await tester.pump(duration);
}
/// 앱이 뒤로 간다 · 앞으로 온다(따로)
void foreground(bool value) =>
(container.read(appForegroundProvider.notifier) as FakeForeground).set(
value,
);
testWidgets('앞에 있는 동안 30초 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다', (
WidgetTester tester,
) async {
await start();
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
await elapse(tester, const Duration(seconds: 10));
comeBack();
await pumpUntil(tester, () => h.notifications.statusCalls >= 2, '다시 읽기');
await elapse(tester, const Duration(seconds: 5));
h.messaging.receive(kNewMailPush);
await elapse(tester, const Duration(seconds: 14));
expect(h.push.seen, hasLength(1), reason: '30초 안이라 건너뛴다');
await elapse(tester, const Duration(seconds: 1));
await pumpUntil(tester, () => h.push.seen.length == 2, '간격 끝에 한 번');
await elapse(tester, const Duration(seconds: 40));
expect(h.push.seen, hasLength(2), reason: '건너뛴 것이 여럿이어도 끝에 한 번');
});
testWidgets('간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 한 번 보내고, 끝에는 보내지 않는다', (
WidgetTester tester,
) async {
await start();
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
await elapse(tester, const Duration(seconds: 10));
comeBack();
await pumpUntil(tester, () => h.notifications.statusCalls >= 2, '다시 읽기');
await elapse(tester, const Duration(seconds: 5));
expect(h.push.seen, hasLength(1), reason: '30초 안이라 기다린다');
// 직원이 마지막으로 본 때는 지금이다 — 간격 끝(뒤에 있는 동안)에 보내면 그사이 온 새 메일까지 본 것으로 센다
foreground(false);
await pumpUntil(tester, () => h.push.seen.length == 2, '뒤로 갈 때 한 번');
await elapse(tester, const Duration(seconds: 40));
expect(h.push.seen, hasLength(2), reason: '뒤에 있는 동안은 보내지 않는다');
// 다시 앞으로 오면 그때 규칙대로(마지막으로 보낸 지 30초가 지났다 — 바로)
foreground(true);
await pumpUntil(tester, () => h.push.seen.length == 3, '다시 앞으로 옴');
});
testWidgets('로그아웃하면 기다리던 봤음을 보내지 않는다', (WidgetTester tester) async {
await start();
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
comeBack();
await tester.pump(const Duration(milliseconds: 10));
unawaited(container.read(sessionProvider.notifier).signOut());
await pumpUntil(tester, () => h.messaging.unregisterCalls == 1, '로그아웃');
await elapse(tester, const Duration(seconds: 31));
expect(h.push.seen, hasLength(1));
});
});
group('켜기 · 끄기 (설정 스위치 · 권하기 줄)', () {
test('켜기 — 허락 전이면 시스템 창을 띄우고, 허락하면 PUT 이 끝난 뒤 켬을 둔다(알림을 켠 요청)', () async {
h.prefs.values = const PushPreferenceValues(enabled: false);
await start(permission: PushPermission.notAsked);
await signedIn();
await idle();
expect(pushRowStatusOf(push()), PushRowStatus.notAsked);
h.push.putGate = Completer<void>();
final Future<PushTurnOnResult> turning = controller().turnOn();
await until(() => h.push.puts.isNotEmpty, 'PUT 보냄');
expect(pushRowStatusOf(push()), PushRowStatus.turningOn);
expect(h.prefs.values.enabled, isFalse, reason: 'PUT 이 끝나야 켬을 둔다');
h.push.putGate!.complete();
expect(await turning, PushTurnOnResult.on);
expect(h.messaging.permissionRequests, 1);
expect(h.push.puts.single.body['fid'], 'fid-1');
final RequestOptions sent = h.server
.received('PUT', ApiPaths.pushRegistration)
.single;
expect(sent.extra[kSilentExtraKey], isNot(isTrue));
expect(h.prefs.values.enabled, isTrue);
expect(pushRowStatusOf(push()), PushRowStatus.on);
});
test(
'켜기 — [허용 안 함]은 denied, 막혔으면 창 없이 blocked, 채널만 꺼졌으면 channelOff',
() async {
h.prefs.values = const PushPreferenceValues(enabled: false);
await start(permission: PushPermission.notAsked);
await signedIn();
h.messaging.answer = PushPermission.denied;
expect(await controller().turnOn(), PushTurnOnResult.denied);
h.messaging
..current = PushPermission.blocked
..answer = null;
final int asked = h.messaging.permissionRequests;
expect(await controller().turnOn(), PushTurnOnResult.blocked);
expect(h.messaging.permissionRequests, asked);
h.messaging.current = PushPermission.granted;
h.notifications.current = const NotificationStatus(
appEnabled: true,
channelEnabled: false,
sdkInt: 34,
);
expect(await controller().turnOn(), PushTurnOnResult.channelOff);
expect(h.push.puts, isEmpty);
expect(h.prefs.values.enabled, isFalse);
},
);
test('켜기 — PUT 이 실패하면 켬을 두지 않고 그 코드를 알린다', () async {
h.prefs.values = const PushPreferenceValues(enabled: false);
await start();
await signedIn();
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
expect(await controller().turnOn(), PushTurnOnResult.failed);
expect(h.prefs.values.enabled, isFalse);
expect(pushRowStatusOf(push()), PushRowStatus.off);
expect(h.notifier.shownCodes, <String>['MAIL_001']);
});
test(
'켜기 — 처음 값(켬)인 휴대폰이 허락 전에서 켰는데 PUT 이 실패하면 끔으로 두고 저절로 다시 하지 않는다',
() async {
await start(permission: PushPermission.notAsked);
await signedIn();
await until(() => push().loaded && push().sessionReady, '읽기');
expect(h.prefs.values.enabled, isTrue, reason: '처음 값');
expect(pushRowStatusOf(push()), PushRowStatus.notAsked);
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
expect(await controller().turnOn(), PushTurnOnResult.failed);
expect(h.messaging.permissionRequests, 1);
expect(h.notifier.shownCodes, <String>['MAIL_001']);
expect(pushRowStatusOf(push()), PushRowStatus.off);
expect(h.prefs.values.enabled, isFalse);
// 앞으로 와도 조용히 다시 올리지 않는다(직원이 다시 켤 때까지)
final int puts = h.push.puts.length;
h.push.putFailure = null;
comeBack();
await idle();
expect(h.push.puts, hasLength(puts));
},
);
test('켜기 — FCM 등록이 실패해 FID 를 받지 못하면 SYS_001 을 알리고 켜지 않는다', () async {
h.prefs.values = const PushPreferenceValues(enabled: false);
h.messaging.registerError = PlatformException(code: 'test');
await start();
await signedIn();
expect(await controller().turnOn(), PushTurnOnResult.failed);
expect(h.push.puts, isEmpty);
expect(h.notifier.shownCodes, <String>['SYS_001']);
});
test('끄기 — DELETE 가 끝나야 끔을 두고, 실패하면 켬 그대로', () async {
await start();
await until(() => push().registered, '등록');
h.push.deleteFailure = FakeResponse.fail(503, 'SYS_001');
expect(await controller().turnOff(), PushTurnOffResult.failed);
expect(h.prefs.values.enabled, isTrue);
expect(pushRowStatusOf(push()), PushRowStatus.on);
expect(h.notifier.shownCodes, <String>['SYS_001']);
h.push.deleteFailure = null;
expect(await controller().turnOff(), PushTurnOffResult.off);
expect(h.prefs.values.enabled, isFalse);
expect(pushRowStatusOf(push()), PushRowStatus.off);
expect(push().registered, isFalse);
expect(
h.push.deletes.last.extra[kSilentExtraKey],
isNot(isTrue),
reason: '직원이 끈 것은 실패를 알린다',
);
});
test('권하기 줄 — 안드로이드 13 이상 · 묻지 않음 · 켬일 때 보이고, 닫으면 이 휴대폰에 남는다', () async {
await start(permission: PushPermission.notAsked);
await signedIn();
await until(() => push().loaded && push().sessionReady, '읽기');
expect(showsPushOffer(push()), isTrue);
controller().dismissOffer();
expect(showsPushOffer(push()), isFalse);
await until(() => h.prefs.values.offerDismissed, '닫음 기억');
});
test('[설정 열기]는 휴대폰 설정을 연다', () async {
await start();
expect(await controller().openSettings(), isTrue);
h.notifications.opens = false;
expect(await controller().openSettings(), isFalse);
expect(h.notifications.settingsOpened, 2);
});
});
test('받을 수 없는 휴대폰(Firebase 를 띄우지 못함)은 아무것도 하지 않는다', () async {
h.messaging.available = false;
await start();
await signedIn();
await idle();
expect(push().deviceSupported, isFalse);
expect(pushRowStatusOf(push()), PushRowStatus.unsupported);
expect(h.messaging.calls, isEmpty);
expect(h.notifications.statusCalls, 0);
expect(h.push.puts, isEmpty);
expect(await controller().turnOn(), PushTurnOnResult.unavailable);
});
}
@@ -0,0 +1,181 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/push/firebase_push_messaging.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 플랫폼 값 → 앱 값 (권한 · 알림 데이터 · 알림창 채널의 답)
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('알림 권한 (firebase_messaging 17 → PushPermission)', () {
test('허락 · 임시 허락은 granted, 묻지 않음은 notAsked, 다시 묻지 못함은 blocked', () {
for (final int? sdk in <int?>[null, 30, 33, 35]) {
expect(
pushPermissionOf(AuthorizationStatus.authorized, sdkInt: sdk),
PushPermission.granted,
);
expect(
pushPermissionOf(AuthorizationStatus.provisional, sdkInt: sdk),
PushPermission.granted,
);
expect(
pushPermissionOf(AuthorizationStatus.notDetermined, sdkInt: sdk),
PushPermission.notAsked,
);
expect(
pushPermissionOf(AuthorizationStatus.deniedPermanently, sdkInt: sdk),
PushPermission.blocked,
);
}
});
test('거절은 안드로이드 13 이상(또는 판 모름)이면 다시 물을 수 있고, 12 이하는 휴대폰 설정에서 끈 것이다', () {
expect(
pushPermissionOf(AuthorizationStatus.denied, sdkInt: 33),
PushPermission.denied,
);
expect(
pushPermissionOf(AuthorizationStatus.denied),
PushPermission.denied,
);
expect(
pushPermissionOf(AuthorizationStatus.denied, sdkInt: 32),
PushPermission.blocked,
);
expect(
pushPermissionOf(AuthorizationStatus.denied, sdkInt: 24),
PushPermission.blocked,
);
});
});
group('알림 데이터 → PushMessage (모듈 · 종류만)', () {
test('새 메일 알림 — 모듈 메일 · 종류 new-mail · 항목 없음(늘 받은편지함)', () {
final PushMessage message = pushMessageOf(
const RemoteMessage(
data: <String, Object?>{'module': 'mail', 'kind': 'new-mail'},
notification: RemoteNotification(title: '새 메일 3통'),
),
);
expect(message.target?.module, AppModuleId.mail);
expect(message.target?.itemId, isNull);
expect(message.kind, PushKinds.newMail);
expect(message.isNewMail, isTrue);
});
test('세션 끝 알림은 새 메일이 아니다 · 종류가 없는 메일 알림은 새 메일로 본다 · 모르는 모듈은 갈 곳이 없다', () {
expect(
PushMessage.fromData(const <String, Object?>{
'module': 'mail',
'kind': 'session-ended',
}).isNewMail,
isFalse,
);
expect(
PushMessage.fromData(const <String, Object?>{
'module': 'mail',
}).isNewMail,
isTrue,
);
final PushMessage unknown = PushMessage.fromData(const <String, Object?>{
'module': 'chat',
'kind': 'new-mail',
});
expect(unknown.target, isNull);
expect(unknown.isNewMail, isFalse);
expect(
PushMessage.fromData(const <String, Object?>{
'module': 'mail',
'kind': '',
}).kind,
isNull,
);
});
});
group('알림창 채널 (Kotlin NewMailNotifications 와 짝)', () {
const MethodChannel channel = PlatformNotificationSystem.channel;
final List<String> calls = <String>[];
tearDown(() {
calls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
void answer(Object? Function(MethodCall call) reply) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (MethodCall call) async {
calls.add(call.method);
return reply(call);
});
}
test('status 의 칸을 읽고, 모양이 틀린 칸은 켜짐 · 판 모름으로 본다', () async {
answer(
(_) => <String, Object?>{
'appEnabled': true,
'channelEnabled': false,
'sdkInt': 34,
},
);
expect(
await const PlatformNotificationSystem().status(),
const NotificationStatus(
appEnabled: true,
channelEnabled: false,
sdkInt: 34,
),
);
answer((_) => <String, Object?>{'appEnabled': 'yes', 'sdkInt': '34'});
expect(
await const PlatformNotificationSystem().status(),
const NotificationStatus(appEnabled: true, channelEnabled: true),
);
expect(calls, <String>['status', 'status']);
});
test('채널이 실패하거나 없으면 막지 않는 쪽 — 상태는 모름(켜짐), 설정 열기는 거짓, 지우기는 조용히', () async {
answer((_) => throw PlatformException(code: 'test'));
expect(
await const PlatformNotificationSystem().status(),
NotificationStatus.unknown,
);
expect(await const PlatformNotificationSystem().openSettings(), isFalse);
await const PlatformNotificationSystem().clearNewMail();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
expect(
await const PlatformNotificationSystem().status(),
NotificationStatus.unknown,
);
expect(await const PlatformNotificationSystem().openSettings(), isFalse);
});
test('openSettings 는 opened 일 때만 참, clearNewMail 은 그 이름으로 부른다', () async {
answer(
(MethodCall call) => call.method == 'openSettings' ? 'opened' : null,
);
expect(await const PlatformNotificationSystem().openSettings(), isTrue);
await const PlatformNotificationSystem().clearNewMail();
expect(calls, <String>['openSettings', 'clearNewMail']);
answer((_) => 'failed');
expect(await const PlatformNotificationSystem().openSettings(), isFalse);
});
});
test('받을 수 없는 휴대폰의 알림 받기는 비어 있다', () async {
const UnavailablePushMessaging messaging = UnavailablePushMessaging();
expect(messaging.isAvailable, isFalse);
expect(await messaging.permission(), PushPermission.blocked);
expect(await messaging.initialOpen(), isNull);
expect(await messaging.onRegistered.isEmpty, isTrue);
});
}
@@ -0,0 +1,110 @@
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/push/push_registration.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
/// 새 메일 알림 등록 API — 계약 1절 "앱 새 메일 알림 · 앱 설정"
void main() {
late ApiHarness api;
late PushRegistrationRepository repository;
setUp(() async {
api = ApiHarness();
await api.prepare();
await api.signIn();
repository = PushRegistrationRepository(api.dio);
});
test(
'등록 — PUT /push-registrations/current { fid, platform: android, appVersion } · Bearer',
() async {
api.server.on(
'PUT',
ApiPaths.pushRegistration,
(_) => FakeResponse.ok(<String, Object?>{
'updatedAt': '2026-11-07T05:00:00.000Z',
}),
);
await repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: true);
final RequestOptions sent = api.server.requests.single;
expect(sent.method, 'PUT');
expect(sent.path, '/push-registrations/current');
expect(sent.data, <String, Object?>{
'fid': 'fid-1',
'platform': 'android',
'appVersion': '1.0.0',
});
expect(bearerTokenOf(sent), 'access-1');
expect(sent.extra[kSilentExtraKey], isTrue);
},
);
test('저절로 하는 맞추기는 알리지 않고, 직원이 켜고 끈 것은 알린다', () async {
api.server
..on(
'PUT',
ApiPaths.pushRegistration,
(_) => FakeResponse.fail(503, 'MAIL_001'),
)
..on(
'DELETE',
ApiPaths.pushRegistration,
(_) => FakeResponse.fail(503, 'SYS_001'),
);
await errorOf(
repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: true),
);
await errorOf(repository.delete(silent: true));
expect(api.notifier.shownCodes, isEmpty);
await errorOf(
repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: false),
);
await errorOf(repository.delete(silent: false));
expect(api.notifier.shownCodes, <String>['MAIL_001', 'SYS_001']);
});
test('지우기 — DELETE /push-registrations/current (204)', () async {
api.server.on(
'DELETE',
ApiPaths.pushRegistration,
(_) => const FakeResponse(204, null, rawBody: ''),
);
await repository.delete(silent: true);
expect(api.server.requests.single.path, '/push-registrations/current');
expect(api.server.requests.single.method, 'DELETE');
});
test(
'봤음 — DELETE /push-registrations/current/unseen (204) · 알리지 않는다',
() async {
api.server.on(
'DELETE',
ApiPaths.pushRegistrationUnseen,
(_) => const FakeResponse(204, null, rawBody: ''),
);
await repository.markSeen();
final RequestOptions sent = api.server.requests.single;
expect(sent.path, '/push-registrations/current/unseen');
expect(sent.extra[kSilentExtraKey], isTrue);
api.server.on(
'DELETE',
ApiPaths.pushRegistrationUnseen,
(_) => FakeResponse.fail(503, 'SYS_001'),
);
await errorOf(repository.markSeen());
expect(api.notifier.shownCodes, isEmpty);
},
);
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_state.dart';
import 'package:flutter_test/flutter_test.dart';
/// 켜짐 — 받을 수 있는 휴대폰 · 세션 · 허락 · 채널 켜짐 · 켬 (안드로이드 14)
const PushState _on = PushState(
loaded: true,
deviceSupported: true,
server: PushServer.available,
sessionReady: true,
permission: PushPermission.granted,
sdkInt: 34,
);
void main() {
group('설정 › 새 메일 알림 줄의 모습 (보드 App1Push ④ — 앱 디자인 README 의 표)', () {
test('표 — 받을 수 없음 > 쓸 수 없음 > 켜는 중 · 끄는 중 > 막힘 > 허락 전 > 채널 꺼짐 > 켬 · 끔', () {
final List<(PushState, PushRowStatus)> table =
<(PushState, PushRowStatus)>[
(_on, PushRowStatus.on),
(_on.copyWith(enabled: false), PushRowStatus.off),
(
_on.copyWith(permission: PushPermission.notAsked),
PushRowStatus.notAsked,
),
(
_on.copyWith(permission: PushPermission.denied),
PushRowStatus.notAsked,
),
(
_on.copyWith(permission: PushPermission.notAsked, enabled: false),
PushRowStatus.notAsked,
),
(
_on.copyWith(permission: PushPermission.blocked),
PushRowStatus.blocked,
),
(
_on.copyWith(permission: PushPermission.blocked, enabled: false),
PushRowStatus.blocked,
),
(_on.copyWith(channelEnabled: false), PushRowStatus.channelOff),
(
_on.copyWith(channelEnabled: false, enabled: false),
PushRowStatus.channelOff,
),
(
_on.copyWith(server: PushServer.unavailable),
PushRowStatus.unavailable,
),
(_on.copyWith(server: PushServer.unknown), PushRowStatus.on),
(const PushState(loaded: true), PushRowStatus.unsupported),
(
_on.copyWith(change: PushChange.turningOn, enabled: false),
PushRowStatus.turningOn,
),
(
_on.copyWith(change: PushChange.turningOff),
PushRowStatus.turningOff,
),
(
const PushState(deviceSupported: true, sessionReady: true),
PushRowStatus.loading,
),
(
const PushState(loaded: true, deviceSupported: true),
PushRowStatus.loading,
),
];
for (final (PushState state, PushRowStatus expected) in table) {
expect(pushRowStatusOf(state), expected, reason: '$state');
}
});
test('켜는 중 표시를 지우면(clearChange) 원래 모습으로 돌아온다', () {
final PushState busy = _on.copyWith(change: PushChange.turningOn);
expect(
pushRowStatusOf(busy.copyWith(clearChange: true)),
PushRowStatus.on,
);
});
});
group('받은편지함 위 권하기 줄', () {
final PushState asking = _on.copyWith(permission: PushPermission.notAsked);
test('안드로이드 13 이상 · 묻지 않음 · 켬 · 서버 · 휴대폰 · 세션 · 닫지 않음이면 보인다', () {
expect(showsPushOffer(asking), isTrue);
expect(showsPushOffer(asking.copyWith(sdkInt: 33)), isTrue);
});
test('하나라도 아니면 보이지 않는다', () {
expect(showsPushOffer(asking.copyWith(sdkInt: 32)), isFalse);
expect(showsPushOffer(const PushState(loaded: true)), isFalse);
expect(showsPushOffer(_on), isFalse, reason: '이미 허락');
expect(
showsPushOffer(asking.copyWith(permission: PushPermission.denied)),
isFalse,
);
expect(showsPushOffer(asking.copyWith(enabled: false)), isFalse);
expect(showsPushOffer(asking.copyWith(offerDismissed: true)), isFalse);
expect(
showsPushOffer(asking.copyWith(server: PushServer.unavailable)),
isFalse,
);
expect(showsPushOffer(asking.copyWith(sessionReady: false)), isFalse);
expect(
showsPushOffer(asking.copyWith(change: PushChange.turningOn)),
isFalse,
);
expect(
showsPushOffer(
const PushState(
deviceSupported: true,
sessionReady: true,
permission: PushPermission.notAsked,
sdkInt: 34,
),
),
isFalse,
reason: '값을 읽기 전',
);
});
});
group('보내야 하나 · 맞출 수 있나', () {
test('켬 ∧ 허락 ∧ 새 메일 채널일 때만 받고 싶다', () {
expect(pushWanted(_on), isTrue);
expect(pushWanted(_on.copyWith(enabled: false)), isFalse);
expect(
pushWanted(_on.copyWith(permission: PushPermission.notAsked)),
isFalse,
);
expect(pushWanted(_on.copyWith(channelEnabled: false)), isFalse);
});
test('읽었고 받을 수 있는 휴대폰 · 서버(모르면 된다) · 세션일 때만 맞춘다', () {
expect(pushReachable(_on), isTrue);
expect(pushReachable(_on.copyWith(server: PushServer.unknown)), isTrue);
expect(
pushReachable(_on.copyWith(server: PushServer.unavailable)),
isFalse,
);
expect(pushReachable(_on.copyWith(sessionReady: false)), isFalse);
expect(pushReachable(_on.copyWith(loaded: false)), isFalse);
expect(pushReachable(const PushState(loaded: true)), isFalse);
});
});
}
@@ -114,7 +114,7 @@ void main() {
expect(resolveSessionRedirect(_signedIn(), '/files/f1'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/nowhere'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/settings/about'), isNull);
expect(resolveSessionRedirect(_signedIn(), '/settings/licenses'), isNull);
});
test('홈은 넘겨받은 모듈 목록을 따른다 — 메일이 꺼졌으면 켜진 첫 모듈로', () {
@@ -152,4 +152,70 @@ void main() {
expect(SessionRestriction.parse('unknown'), SessionRestriction.unknown);
});
});
group('앱을 업데이트해야 할 때 (최소 판)', () {
test('세션과 상관없이 어느 화면에서든 업데이트 화면 — 로그인 전 화면 · 되살리는 중 · 제한 단계도', () {
final List<(AsyncValue<SessionState>, String)> cases =
<(AsyncValue<SessionState>, String)>[
(const AsyncLoading<SessionState>(), AppRoutes.start),
(const AsyncData<SessionState>(SignedOut()), AppRoutes.login),
(
const AsyncData<SessionState>(
CodeRequired('ara.kim@arasystem.it.kr'),
),
AppRoutes.loginCode,
),
(
_signedIn(SessionRestriction.passwordChange),
AppRoutes.passwordChange,
),
(_signedIn(), homeLocation),
(_signedIn(), '/mail/compose'),
(_signedIn(), '/settings/licenses'),
];
for (final (AsyncValue<SessionState> session, String location) in cases) {
expect(
resolveSessionRedirect(session, location, updateRequired: true),
AppRoutes.update,
reason: location,
);
expect(
resolveSessionRedirect(
session,
AppRoutes.update,
updateRequired: true,
),
isNull,
);
}
});
test('풀리면 업데이트 화면은 세션이 고른 화면으로 돌아간다', () {
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.update),
homeLocation,
);
expect(
resolveSessionRedirect(
const AsyncData<SessionState>(SignedOut()),
AppRoutes.update,
),
AppRoutes.login,
);
expect(
resolveSessionRedirect(
const AsyncLoading<SessionState>(),
AppRoutes.update,
),
AppRoutes.start,
);
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.twoFactorSetup),
AppRoutes.update,
),
AppRoutes.twoFactorSetup,
);
});
});
}
@@ -0,0 +1,139 @@
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart';
import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart';
import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
/// 시험 앱의 패키지 이름
const String _package = 'kr.it.arasystem.app';
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다
void main() {
late LoginHarness h;
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다(주소마다)
final List<({Uri url, LaunchMode mode})> launched =
<({Uri url, LaunchMode mode})>[];
late bool Function(Uri url) opens;
Future<bool> launch(Uri url, LaunchMode mode) async {
launched.add((url: url, mode: mode));
return opens(url);
}
setUp(() {
h = LoginHarness();
launched.clear();
opens = (Uri _) => true;
h.server.on(
'GET',
ApiPaths.appConfig,
(_) => FakeResponse.ok(<String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '2.0.0'},
'pushAvailable': true,
}),
);
});
Future<void> pump(WidgetTester tester, {String? packageName = _package}) =>
h.pump(
tester,
overrides: <Override>[
appVersionProvider.overrideWithValue('1.0.0'),
appPackageNameProvider.overrideWithValue(packageName),
appStoreLauncherProvider.overrideWithValue(
AppStoreLauncher(launch: launch),
),
],
);
Finder storeButton() =>
find.widgetWithText(FilledButton, AppUpdateTexts.openStore);
Finder snack(String text) =>
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
testWidgets('제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄(연락처)', (
WidgetTester tester,
) async {
h.supportContact('정보보안팀 02-1234-5678');
await pump(tester);
expect(find.byType(AppUpdatePage), findsOneWidget);
expect(find.text(ScreenTitles.update), findsOneWidget);
expect(findCopy(AppUpdateTexts.message), findsOneWidget);
expect(storeButton(), findsOneWidget);
expect(find.text(AppUpdateTexts.currentVersion('1.0.0')), findsOneWidget);
expect(
findCopy('${AppUpdateTexts.supportQuestion}\n정보보안팀 02-1234-5678'),
findsOneWidget,
);
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async {
h.supportContact(null);
await pump(tester);
expect(findCopy(AppUpdateTexts.supportFallback), findsOneWidget);
});
testWidgets('[Play 스토어 열기] — Play 스토어 앱부터, 열리지 않으면 웹 주소를 바깥 앱으로', (
WidgetTester tester,
) async {
opens = (Uri url) => url.scheme != 'market';
await pump(tester);
await tester.tap(storeButton());
await tester.pumpAndSettle();
expect(launched, hasLength(2));
expect(launched.first.url.toString(), 'market://details?id=$_package');
expect(launched.first.mode, LaunchMode.externalNonBrowserApplication);
expect(
launched.last.url.toString(),
'https://play.google.com/store/apps/details?id=$_package',
);
expect(launched.last.mode, LaunchMode.externalApplication);
expect(snack(AppUpdateTexts.openStoreFailed), findsNothing);
});
testWidgets('Play 스토어 앱이 열리면 웹 주소는 열지 않는다', (WidgetTester tester) async {
await pump(tester);
await tester.tap(storeButton());
await tester.pumpAndSettle();
expect(launched, hasLength(1));
expect(launched.single.url.scheme, 'market');
});
testWidgets('둘 다 열지 못하면 직접 찾아 업데이트해 달라고 알린다', (WidgetTester tester) async {
opens = (Uri _) => false;
await pump(tester);
await tester.tap(storeButton());
await tester.pumpAndSettle();
expect(launched, hasLength(2));
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
});
testWidgets('패키지 이름을 읽지 못했으면 열지 않고 알린다', (WidgetTester tester) async {
await pump(tester, packageName: null);
await tester.tap(storeButton());
await tester.pumpAndSettle();
expect(launched, isEmpty);
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
});
}
@@ -8,10 +8,17 @@ import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/app_config/app_config_controller.dart';
import 'package:arasystem_app/core/app_config/app_config_repository.dart';
import 'package:arasystem_app/core/constants/login_texts.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_events.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_registration.dart';
import 'package:arasystem_app/core/push/push_state.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart';
@@ -1850,6 +1857,15 @@ bool _allowedProviderValue(Object? value) =>
value is MailChange ||
value is MailToast ||
value is Map<AppModuleId, int> ||
// 앱 설정(최소 판 · 알림을 쓸 수 있는지)과 새 메일 알림 — FID · 토큰은 상태에 두지 않는다
value is AppConfigState ||
value is AppConfigRepository ||
value is PushState ||
value is PushEvent ||
value is PushMessaging ||
value is NotificationSystem ||
value is PushPreferences ||
value is PushRegistrationRepository ||
value is bool;
/// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다
@@ -493,6 +493,78 @@ void main() {
});
});
group('새 메일 알림 (기기 등록)', () {
Future<void> signedIn() async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
expect(await settled(), isA<SignedIn>());
await pumpEventsUntil(() => h.push.registered == 1, reason: '등록');
}
test('세션이 끝나면(AUTH_002) 토큰을 지운 뒤 이 기기의 알림을 멈춘다', () async {
await signedIn();
h.clock.advance(const Duration(hours: 2));
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok(<Object?>[]));
await errorOf(container.read(dioProvider).get<Object?>('/mailboxes'));
await pumpEventsUntil(() => h.push.unregistered == 1, reason: '알림 멈춤');
expect(current(), const SignedOut(reason: SignOutReason.expired));
expect(h.push.hadSessionAtUnregister, isFalse);
});
test('앱이 꺼진 사이 세션이 끝났으면(되살리기의 갱신 거절) 이 기기의 알림도 멈춘다', () async {
await start(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
expect(await settled(), const SignedOut());
await pumpEventsUntil(() => h.push.unregistered == 1, reason: '알림 멈춤');
expect(h.push.registered, 0);
});
test('쓰던 세션에 제한이 생기면 멈추고(pause), 풀리면 다시 등록한다', () async {
await signedIn();
final SessionEventBus bus = container.read(sessionEventsProvider);
bus.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange')));
await pumpEventsUntil(
() => restrictionNow() == SessionRestriction.passwordChange,
reason: '제한 반영',
);
expect(h.push.paused, 1);
expect(h.push.unregistered, 0);
bus.emit(SessionRefreshed(sessionJson()));
await pumpEventsUntil(() => h.push.registered == 2, reason: '다시 등록');
expect(h.push.paused, 1);
});
test('제한된 채 로그인하면 멈출 것도 등록할 것도 없다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => signedInWith(1, restriction: 'twoFactorSetup'),
);
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
expect(h.push.registered, 0);
expect(h.push.paused, 0);
});
});
group('제한 다시 맞추기 — 노티파이어의 갈래', () {
Future<void> signedIn() async {
await start(refreshToken: 'refresh-1');
@@ -1098,8 +1098,21 @@ void main() {
h.router.go(SettingsRoutes.home);
await tester.pumpAndSettle();
await tester.tap(find.text('로그아웃'));
final Finder signOut = find.widgetWithText(OutlinedButton, '로그아웃');
await tester.ensureVisible(signOut);
await tester.pumpAndSettle();
await tester.tap(signOut);
await tester.pumpAndSettle();
// 확인 창의 [로그아웃]
await tester.tap(
find.descendant(
of: find.byType(AlertDialog),
matching: find.text('로그아웃'),
),
);
await tester.pumpAndSettle();
// 확인 창이 닫히는 동안 로그아웃한다 — 마지막 그림에서 떼어진 프로바이더는 그다음 차례에 정리된다
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(MailListPage), findsNothing);
expect(h.container.exists(mailToastProvider), isFalse);
@@ -0,0 +1,184 @@
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_texts.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/login_fakes.dart';
import '../../support/mail_fakes.dart';
import '../../support/push_screen_harness.dart';
/// 받은편지함 위 새 메일 알림 권하기 줄 — 앱 보드 `App1Push` ①(줄) · ③(고른 뒤 알림)
void main() {
late PushScreenHarness h;
setUp(() {
h = PushScreenHarness();
// 처음 모습: 안드로이드 14 · 아직 묻지 않음 — 줄이 보인다
h.messaging.current = PushPermission.notAsked;
});
Finder line() => findCopy(PushTexts.offerMessage);
Finder enableButton() =>
find.widgetWithText(TextButton, PushTexts.offerEnable);
Finder snack(String text) =>
find.descendant(of: find.byType(SnackBar), matching: find.text(text));
testWidgets('보이는 때 — 받은편지함 위 막대 아래, 글 · [알림 켜기] · [닫기]', (
WidgetTester tester,
) async {
final SemanticsHandle semantics = tester.ensureSemantics();
await h.pump(tester);
expect(line(), findsOneWidget);
expect(enableButton(), findsOneWidget);
expect(find.byTooltip(PushTexts.offerDismiss), findsOneWidget);
expect(find.bySemanticsLabel(PushTexts.offerRegion), findsOneWidget);
// 목록 위에 고정이다(목록과 함께 흐르지 않는다)
expect(
tester.getBottomLeft(find.byType(PushOfferLine)).dy,
lessThanOrEqualTo(tester.getTopLeft(find.text('제목 m1')).dy),
);
expect(h.messaging.permissionRequests, 0, reason: '누르기 전에는 묻지 않는다');
semantics.dispose();
});
group('보이지 않는 때', () {
testWidgets('다른 메일함', (WidgetTester tester) async {
await h.pump(tester, location: MailRoutes.mailbox(kClientsId));
expect(find.byType(PushOfferLine), findsNothing, reason: '받은편지함에만 둔다');
});
testWidgets('안드로이드 12 이하(묻지 않고 처음부터 허용)', (WidgetTester tester) async {
h.notifications.current = const NotificationStatus(
appEnabled: true,
channelEnabled: true,
sdkInt: 32,
);
await h.pump(tester);
expect(line(), findsNothing);
});
testWidgets('이미 허용', (WidgetTester tester) async {
h.messaging.current = PushPermission.granted;
await h.pump(tester);
expect(line(), findsNothing);
});
testWidgets('닫은 적 있음', (WidgetTester tester) async {
h.prefs.values = const PushPreferenceValues(offerDismissed: true);
await h.pump(tester);
expect(line(), findsNothing);
});
testWidgets('이 휴대폰에서 끔', (WidgetTester tester) async {
h.prefs.values = const PushPreferenceValues(enabled: false);
await h.pump(tester);
expect(line(), findsNothing);
});
testWidgets('서버가 보낼 수 없음', (WidgetTester tester) async {
h.push.config = <String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
'pushAvailable': false,
};
await h.pump(tester);
expect(line(), findsNothing);
});
testWidgets('메일을 고르는 동안', (WidgetTester tester) async {
await h.pump(tester);
expect(line(), findsOneWidget);
await tester.longPress(find.text('제목 m1'));
await settle(tester);
expect(line(), findsNothing);
});
});
testWidgets('[알림 켜기] → [허용] — "새 메일 알림을 켰습니다." · 서버에 등록하고 줄은 사라진다', (
WidgetTester tester,
) async {
await h.pump(tester);
expect(h.push.puts, isEmpty);
await tester.tap(enableButton());
await settle(tester);
expect(h.messaging.permissionRequests, 1);
expect(snack(PushTexts.enabled), findsOneWidget);
expect(line(), findsNothing);
expect(h.push.puts, hasLength(1));
expect(h.prefs.values.enabled, isTrue);
});
testWidgets('[알림 켜기] → [허용 안 함] — 나중에 켜는 곳을 알리고 줄은 사라진다', (
WidgetTester tester,
) async {
h.messaging.answer = PushPermission.denied;
await h.pump(tester);
await tester.tap(enableButton());
await settle(tester);
expect(snack(PushTexts.deniedLater), findsOneWidget);
expect(line(), findsNothing);
expect(h.push.puts, isEmpty);
});
testWidgets('[알림 켜기] — 휴대폰이 묻지 않고 막혀 있으면 알림 줄에 [설정 열기]', (
WidgetTester tester,
) async {
h.messaging.answer = PushPermission.blocked;
await h.pump(tester);
await tester.tap(enableButton());
await settle(tester);
expect(find.text(PushTexts.blockedNotice), findsOneWidget);
expect(line(), findsNothing);
await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings));
await settle(tester);
expect(h.notifications.settingsOpened, 1);
});
testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', (
WidgetTester tester,
) async {
h.messaging.answer = PushPermission.blocked;
h.notifications.opens = false;
await h.pump(tester);
await tester.tap(enableButton());
await settle(tester);
await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings));
await settle(tester);
expect(snack(PushTexts.openSettingsFailed), findsOneWidget);
});
testWidgets('[닫기] — 줄이 사라지고 이 휴대폰에서 다시 보이지 않는다(묻지도 않는다)', (
WidgetTester tester,
) async {
await h.pump(tester);
await tester.tap(find.byTooltip(PushTexts.offerDismiss));
await settle(tester);
expect(line(), findsNothing);
expect(h.prefs.values.offerDismissed, isTrue);
expect(h.messaging.permissionRequests, 0);
});
}
@@ -0,0 +1,460 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_texts.dart';
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
import '../../support/push_screen_harness.dart';
/// 설정 탭 — 앱 보드 `App1Shell`(설정 한 화면 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄)
void main() {
late PushScreenHarness h;
setUp(() => h = PushScreenHarness());
/// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답한다(연락처 없음)
void supportContact(String? text) {
h.server.on(
'GET',
ApiPaths.supportContact,
(_) => FakeResponse.ok(<String, Object?>{'text': text}),
);
}
Finder dialog() => find.byType(AlertDialog);
Finder inDialog(String text) =>
find.descendant(of: dialog(), matching: find.text(text));
Finder signOutButton() =>
find.widgetWithText(OutlinedButton, SettingsTexts.signOut);
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
await tester.tap(finder);
await settle(tester);
}
group('설정 한 화면', () {
testWidgets('절 — 내 정보(이름 · 주소) · 알림 · 앱 정보(판) · 문의(연락처) · [로그아웃]', (
WidgetTester tester,
) async {
supportContact('정보보안팀 02-1234-5678');
await h.pump(tester);
await h.openSettings(tester);
expect(h.app.location(), '/settings');
for (final String title in <String>[
SettingsTexts.account,
SettingsTexts.notifications,
SettingsTexts.about,
SettingsTexts.support,
]) {
expect(find.text(title), findsOneWidget, reason: title);
}
expect(find.text('김아라'), findsOneWidget);
expect(find.text(kTestEmail), findsOneWidget);
expect(find.text(PushTexts.rowLabel), findsOneWidget);
expect(find.text(SettingsTexts.appVersion), findsOneWidget);
expect(find.text(kPushScreenAppVersion), findsOneWidget);
expect(find.text(SettingsTexts.licenses), findsOneWidget);
expect(
findCopy('${SettingsTexts.supportLead}\n정보보안팀 02-1234-5678'),
findsOneWidget,
);
expect(signOutButton(), findsOneWidget);
});
testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async {
supportContact(null);
await h.pump(tester);
await h.openSettings(tester);
expect(findCopy(SettingsTexts.supportFallback), findsOneWidget);
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('[오픈소스 라이선스]는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async {
await h.pump(tester);
await h.openSettings(tester);
await tester.ensureVisible(find.text(SettingsTexts.licenses));
await tester.pumpAndSettle();
await tester.tap(find.text(SettingsTexts.licenses));
// 라이선스 화면은 읽는 동안 둥근 표시가 돈다 — 화면 넘김만 기다린다
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(LicensePage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
});
group('[로그아웃] — 확인 창부터', () {
testWidgets('처음 초점은 [취소] — [취소]면 머물고 로그아웃하지 않는다', (
WidgetTester tester,
) async {
await h.pump(tester);
await h.openSettings(tester);
await tapVisible(tester, signOutButton());
expect(inDialog(SettingsTexts.signOutTitle), findsOneWidget);
expect(
find.descendant(
of: dialog(),
matching: findCopy(SettingsTexts.signOutMessage),
),
findsOneWidget,
);
final TextButton cancel = tester.widget<TextButton>(
find.widgetWithText(TextButton, SettingsTexts.cancel),
);
expect(cancel.autofocus, isTrue);
await tester.tap(inDialog(SettingsTexts.cancel));
await settle(tester);
expect(dialog(), findsNothing);
expect(h.app.location(), '/settings');
expect(h.server.received('POST', ApiPaths.appLogout), isEmpty);
expect(h.messaging.unregisterCalls, 0);
});
testWidgets('[로그아웃]을 고르면 로그인 화면 — 이 휴대폰의 알림 등록도 푼다(서버 줄은 서버가 지운다)', (
WidgetTester tester,
) async {
await h.pump(tester);
expect(h.push.puts, hasLength(1));
await h.openSettings(tester);
await tapVisible(tester, signOutButton());
await tester.tap(inDialog(SettingsTexts.signOut));
await settle(tester);
expect(find.byType(LoginPage), findsOneWidget);
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
expect(h.messaging.unregisterCalls, 1);
expect(h.push.deletes, isEmpty, reason: '로그아웃은 DELETE 를 부르지 않는다');
expect(h.notifications.clears, greaterThan(0));
});
});
group('설정 › 새 메일 알림 줄', () {
Finder switchFinder() => find.byType(Switch);
Switch switchOf(WidgetTester tester) =>
tester.widget<Switch>(switchFinder());
Finder openSettingsButton() =>
find.widgetWithText(TextButton, PushTexts.openSettings);
Finder snack(String text) =>
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
void lifecycle(WidgetTester tester, List<AppLifecycleState> states) {
for (final AppLifecycleState state in states) {
tester.binding.handleAppLifecycleStateChanged(state);
}
}
/// 휴대폰 설정에 다녀온다(앱이 뒤로 갔다 앞으로 온다)
Future<void> visitPhoneSettings(WidgetTester tester) async {
lifecycle(tester, const <AppLifecycleState>[
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
]);
await tester.pump();
lifecycle(tester, const <AppLifecycleState>[
AppLifecycleState.hidden,
AppLifecycleState.inactive,
AppLifecycleState.resumed,
]);
await settle(tester);
}
testWidgets('켜짐 — 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다', (
WidgetTester tester,
) async {
final SemanticsHandle semantics = tester.ensureSemantics();
await h.pump(tester);
await h.openSettings(tester);
expect(switchOf(tester).value, isTrue);
expect(switchOf(tester).onChanged, isNotNull);
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
expect(openSettingsButton(), findsNothing);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(
tester.getSemantics(switchFinder()),
isSemantics(
label: PushTexts.rowLabel,
hint: PushTexts.rowDescription,
hasToggledState: true,
isToggled: true,
hasEnabledState: true,
isEnabled: true,
),
);
semantics.dispose();
});
testWidgets('끄기 — 끄는 중(둥근 표시)에는 다시 받지 않고, 서버 등록을 지운 뒤 꺼진다', (
WidgetTester tester,
) async {
final SemanticsHandle semantics = tester.ensureSemantics();
await h.pump(tester);
await h.openSettings(tester);
h.push.deleteGate = Completer<void>();
await tester.tap(switchFinder());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(h.push.deletes, hasLength(1));
expect(switchOf(tester).value, isFalse, reason: '바꿀 값 쪽에 둔다');
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.bySemanticsLabel(PushTexts.turningOff), findsOneWidget);
// 끄는 중에 다시 눌러도 겹쳐 보내지 않는다
await tester.tap(switchFinder());
await tester.pump(const Duration(milliseconds: 100));
expect(h.push.deletes, hasLength(1));
h.push.deleteGate!.complete();
await settle(tester);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(switchOf(tester).value, isFalse);
expect(h.prefs.values.enabled, isFalse);
expect(h.notifier.shownCodes, isEmpty);
semantics.dispose();
});
testWidgets('켜기(이미 허용) — 켜는 중을 보이고, 서버에 등록한 뒤 켜진다', (
WidgetTester tester,
) async {
final SemanticsHandle semantics = tester.ensureSemantics();
h.prefs.values = const PushPreferenceValues(enabled: false);
await h.pump(tester);
await h.openSettings(tester);
expect(switchOf(tester).value, isFalse);
expect(h.push.puts, isEmpty, reason: '끈 휴대폰은 등록하지 않는다');
h.push.putGate = Completer<void>();
await tester.tap(switchFinder());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(h.push.puts, hasLength(1));
expect(switchOf(tester).value, isTrue, reason: '바꿀 값 쪽에 둔다');
expect(find.bySemanticsLabel(PushTexts.turningOn), findsOneWidget);
h.push.putGate!.complete();
await settle(tester);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(switchOf(tester).value, isTrue);
expect(h.prefs.values.enabled, isTrue);
expect(h.push.puts.single.body['fid'], 'fid-1');
expect(h.messaging.permissionRequests, 0, reason: '이미 허용이면 묻지 않는다');
semantics.dispose();
});
testWidgets('켜기가 서버에서 실패하면 꺼진 채로 돌아가고 그 코드의 사전 문구를 알린다', (
WidgetTester tester,
) async {
h.prefs.values = const PushPreferenceValues(enabled: false);
await h.pump(tester);
await h.openSettings(tester);
h.push.putFailure = FakeResponse.fail(500, 'SYS_001');
await tester.tap(switchFinder());
await settle(tester);
expect(switchOf(tester).value, isFalse);
expect(h.prefs.values.enabled, isFalse);
expect(h.notifier.shownCodes, <String>['SYS_001']);
});
testWidgets('허락 전(처음 값 켬)에서 켜고 [허용]했는데 서버에서 실패하면 꺼진 채로 돌아간다', (
WidgetTester tester,
) async {
h.messaging.current = PushPermission.notAsked;
await h.pump(tester);
await h.openSettings(tester);
expect(h.prefs.values.enabled, isTrue, reason: '처음 값');
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
await tester.tap(switchFinder());
await settle(tester);
expect(h.messaging.permissionRequests, 1);
expect(switchOf(tester).value, isFalse);
expect(switchOf(tester).onChanged, isNotNull);
expect(
findCopy(PushTexts.rowDescription),
findsOneWidget,
reason: '허락은 받았다',
);
expect(h.prefs.values.enabled, isFalse);
expect(h.notifier.shownCodes, <String>['MAIL_001']);
});
testWidgets('허락 전 — 켜면 휴대폰이 묻고, [허용]이면 켜진다', (WidgetTester tester) async {
h.messaging.current = PushPermission.notAsked;
await h.pump(tester);
await h.openSettings(tester);
expect(switchOf(tester).value, isFalse);
expect(switchOf(tester).onChanged, isNotNull);
expect(findCopy(PushTexts.rowNotAsked), findsOneWidget);
expect(h.push.puts, isEmpty);
await tester.tap(switchFinder());
await settle(tester);
expect(h.messaging.permissionRequests, 1);
expect(switchOf(tester).value, isTrue);
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
expect(h.push.puts, hasLength(1));
});
testWidgets('허락 전 — [허용 안 함]이면 꺼진 채 허락 전 글 그대로, 다시 켜면 또 묻는다', (
WidgetTester tester,
) async {
h.messaging
..current = PushPermission.notAsked
..answer = PushPermission.denied;
await h.pump(tester);
await h.openSettings(tester);
await tester.tap(switchFinder());
await settle(tester);
expect(h.messaging.permissionRequests, 1);
expect(switchOf(tester).value, isFalse);
expect(findCopy(PushTexts.rowNotAsked), findsOneWidget);
expect(h.push.puts, isEmpty);
await tester.tap(switchFinder());
await settle(tester);
expect(h.messaging.permissionRequests, 2);
});
testWidgets('휴대폰에서 막힘 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 허용됐으면 켜진다', (
WidgetTester tester,
) async {
h.messaging.current = PushPermission.blocked;
await h.pump(tester);
await h.openSettings(tester);
expect(switchOf(tester).value, isFalse);
expect(switchOf(tester).onChanged, isNull);
expect(findCopy(PushTexts.rowBlocked), findsOneWidget);
expect(openSettingsButton(), findsOneWidget);
expect(h.push.puts, isEmpty);
await tester.tap(openSettingsButton());
await settle(tester);
expect(h.notifications.settingsOpened, 1);
expect(snack(PushTexts.openSettingsFailed), findsNothing);
h.messaging.current = PushPermission.granted;
await visitPhoneSettings(tester);
expect(switchOf(tester).value, isTrue);
expect(openSettingsButton(), findsNothing);
expect(h.push.puts, hasLength(1));
});
testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', (
WidgetTester tester,
) async {
h.messaging.current = PushPermission.blocked;
h.notifications.opens = false;
await h.pump(tester);
await h.openSettings(tester);
await tester.tap(openSettingsButton());
await settle(tester);
expect(snack(PushTexts.openSettingsFailed), findsOneWidget);
});
testWidgets('새 메일 알림만 꺼짐 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 켜졌으면 켜진다', (
WidgetTester tester,
) async {
h.notifications.current = const NotificationStatus(
appEnabled: true,
channelEnabled: false,
sdkInt: 34,
);
await h.pump(tester);
await h.openSettings(tester);
expect(switchOf(tester).value, isFalse);
expect(switchOf(tester).onChanged, isNull);
expect(findCopy(PushTexts.rowChannelOff), findsOneWidget);
expect(openSettingsButton(), findsOneWidget);
expect(h.push.puts, isEmpty);
h.notifications.current = const NotificationStatus(
appEnabled: true,
channelEnabled: true,
sdkInt: 34,
);
await visitPhoneSettings(tester);
expect(switchOf(tester).value, isTrue);
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
expect(h.push.puts, hasLength(1));
});
testWidgets('서버가 보낼 수 없으면 — 스위치는 누를 수 없고 쓸 수 없다는 글, 등록하지 않는다', (
WidgetTester tester,
) async {
h.push.config = <String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
'pushAvailable': false,
};
await h.pump(tester);
await h.openSettings(tester);
expect(switchOf(tester).value, isFalse);
expect(switchOf(tester).onChanged, isNull);
expect(findCopy(PushTexts.rowUnavailable), findsOneWidget);
expect(openSettingsButton(), findsNothing);
// 앱 설정은 세션 되살리기(미리 갱신)를 기다리지 않고 먼저 온다 — 등록하지 않는다. 앞으로 와도 그대로
expect(h.push.puts, isEmpty);
await visitPhoneSettings(tester);
expect(h.push.puts, isEmpty);
expect(h.push.deletes, isEmpty);
});
testWidgets('받을 수 없는 휴대폰이면 — 스위치는 누를 수 없고 받을 수 없다는 글', (
WidgetTester tester,
) async {
h.messaging.available = false;
await h.pump(tester);
await h.openSettings(tester);
expect(switchOf(tester).value, isFalse);
expect(switchOf(tester).onChanged, isNull);
expect(findCopy(PushTexts.rowUnsupported), findsOneWidget);
expect(h.messaging.registerCalls, 0);
expect(h.push.puts, isEmpty);
});
});
}
+4
View File
@@ -109,6 +109,7 @@ class RecordingPushRegistrar implements PushRegistrar {
final TokenStore _store;
int registered = 0;
int unregistered = 0;
int paused = 0;
bool? hadSessionAtUnregister;
/// 두면 지우기가 이것이 끝날 때까지 기다린다
@@ -123,6 +124,9 @@ class RecordingPushRegistrar implements PushRegistrar {
hadSessionAtUnregister = _store.hasSession;
await unregisterGate?.future;
}
@override
void pause() => paused++;
}
/// 알림을 띄우지 않고 적어 두는 알림 — 겹침 거르기([ErrorNotifier.show])는 그대로 탄다
+2
View File
@@ -140,6 +140,7 @@ class LoginHarness {
WidgetTester tester, {
String? refreshToken,
List<ProviderObserver> observers = const <ProviderObserver>[],
List<Override> overrides = const <Override>[],
}) async {
tester.view.physicalSize = const Size(390 * 3, 1500 * 3);
tester.view.devicePixelRatio = 3;
@@ -173,6 +174,7 @@ class LoginHarness {
overrides: <Override>[
...base.overrides,
authenticatorLauncherProvider.overrideWithValue(launcher),
...overrides,
],
child: const App(),
),
+7 -1
View File
@@ -140,6 +140,9 @@ class FakeMailBackend {
FakeResponse? deletionFailure;
FakeResponse? stateFailure;
/// 있으면 다음 새 메일 확인 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다)
Completer<void>? stateGate;
/// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce])
Completer<void>? listGate;
bool listGateOnce = true;
@@ -364,7 +367,10 @@ class FakeMailBackend {
'skippedIds': skipped,
});
});
server.on('GET', MailApiPaths.mailState, (_) {
server.on('GET', MailApiPaths.mailState, (_) async {
final Completer<void>? gate = stateGate;
stateGate = null;
await gate?.future;
final FakeResponse? failure = stateFailure;
if (failure != null) return failure;
return FakeResponse.ok(<String, Object?>{'state': state});
+4 -1
View File
@@ -25,6 +25,9 @@ class ProviderHarness {
late final RecordingNotifier notifier = RecordingNotifier(clock);
late final RecordingPushRegistrar push = RecordingPushRegistrar(store);
/// 거짓이면 기기 등록을 기록하는 가짜로 바꾸지 않는다 — 세션이 진짜 새 메일 알림 컨트롤러를 부른다(알림 화면 시험)
bool recordPush = true;
/// ProviderScope · ProviderContainer 에 넣을 덮어쓰기
List<Override> get overrides => <Override>[
tokenStoreProvider.overrideWithValue(store),
@@ -32,7 +35,7 @@ class ProviderHarness {
clockProvider.overrideWithValue(clock.call),
errorNotifierProvider.overrideWithValue(notifier),
userAgentProvider.overrideWithValue(kTestUserAgent),
pushRegistrarProvider.overrideWithValue(push),
if (recordPush) pushRegistrarProvider.overrideWithValue(push),
];
/// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너
+339
View File
@@ -0,0 +1,339 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_target.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'fakes.dart';
/// 새 메일 알림 (서버가 싣는 데이터 `{ module: 'mail', kind: 'new-mail' }`)
const PushMessage kNewMailPush = PushMessage(
target: PushTarget(module: AppModuleId.mail),
kind: PushKinds.newMail,
);
/// 로그인이 저절로 끝났다는 알림
const PushMessage kSessionEndedPush = PushMessage(
target: PushTarget(module: AppModuleId.mail),
kind: PushKinds.sessionEnded,
);
/// 가짜 FCM — 권한 · 등록 · 소식을 시험이 손으로 보낸다. 부른 차례를 [calls] 에 적는다
class FakePushMessaging implements PushMessaging {
FakePushMessaging({this.available = true});
bool available;
/// 지금 권한
PushPermission current = PushPermission.notAsked;
/// [requestPermission] 이 고를 값 — null 이면 창이 뜨지 않는다(지금 권한 그대로)
PushPermission? answer = PushPermission.granted;
int permissionRequests = 0;
/// [register] 가 끝나면 보낼 FID — null 이면 소식을 보내지 않는다(시험이 [emitFid] 로)
String? fidOnRegister = 'fid-1';
Object? registerError;
Object? unregisterError;
Completer<void>? registerGate;
Completer<void>? unregisterGate;
/// 꺼져 있던 앱을 띄운 알림
PushMessage? initial;
/// 부른 차례 — 'register' · 'unregister' · 'requestPermission'
final List<String> calls = <String>[];
final StreamController<String> _registered =
StreamController<String>.broadcast();
final StreamController<PushMessage> _foreground =
StreamController<PushMessage>.broadcast();
final StreamController<PushMessage> _opened =
StreamController<PushMessage>.broadcast();
int get registerCalls =>
calls.where((String call) => call == 'register').length;
int get unregisterCalls =>
calls.where((String call) => call == 'unregister').length;
/// FCM 이 FID 를 알린다(등록 · FID 바뀜)
void emitFid(String fid) => _registered.add(fid);
/// 앱이 앞에 있을 때 알림이 왔다
void receive(PushMessage message) => _foreground.add(message);
/// 앱이 뒤에 있을 때 알림을 눌렀다
void tap(PushMessage message) => _opened.add(message);
@override
bool get isAvailable => available;
@override
Future<PushPermission> permission() async => current;
@override
Future<PushPermission> requestPermission() async {
permissionRequests++;
calls.add('requestPermission');
final PushPermission? chosen = answer;
if (chosen != null) current = chosen;
return current;
}
@override
Future<void> register() async {
calls.add('register');
await registerGate?.future;
final Object? error = registerError;
if (error != null) throw error;
final String? fid = fidOnRegister;
if (fid != null) scheduleMicrotask(() => _registered.add(fid));
}
/// [unregister] 를 부를 때 시험이 볼 것(그때 토큰이 남아 있었나 등)
void Function()? onUnregister;
@override
Future<void> unregister() async {
calls.add('unregister');
onUnregister?.call();
await unregisterGate?.future;
final Object? error = unregisterError;
if (error != null) throw error;
}
@override
Stream<String> get onRegistered => _registered.stream;
@override
Stream<PushMessage> get onForegroundMessage => _foreground.stream;
@override
Stream<PushMessage> get onOpened => _opened.stream;
@override
Future<PushMessage?> initialOpen() async {
final PushMessage? message = initial;
initial = null;
return message;
}
}
/// 가짜 알림창 · 알림 설정
class FakeNotificationSystem implements NotificationSystem {
NotificationStatus current = const NotificationStatus(
appEnabled: true,
channelEnabled: true,
sdkInt: 34,
);
int statusCalls = 0;
int clears = 0;
int settingsOpened = 0;
/// [openSettings] 가 열었다고 답하는가
bool opens = true;
@override
Future<NotificationStatus> status() async {
statusCalls++;
return current;
}
@override
Future<bool> openSettings() async {
settingsOpened++;
return opens;
}
@override
Future<void> clearNewMail() async => clears++;
}
/// 메모리 새 메일 알림 값 — 쓴 값을 [values] 로 본다
class MemoryPushPreferences implements PushPreferences {
MemoryPushPreferences([this.values = PushPreferenceValues.initial]);
PushPreferenceValues values;
/// 두면 읽기가 이것이 끝날 때까지 기다린다
Completer<void>? loadGate;
@override
Future<PushPreferenceValues> load() async {
await loadGate?.future;
return values;
}
PushPreferenceValues _with({
bool? enabled,
bool? offerDismissed,
bool? pendingUnregister,
Object? lastFid = _keep,
bool? serverRegistered,
}) => PushPreferenceValues(
enabled: enabled ?? values.enabled,
offerDismissed: offerDismissed ?? values.offerDismissed,
pendingUnregister: pendingUnregister ?? values.pendingUnregister,
lastFid: identical(lastFid, _keep) ? values.lastFid : lastFid as String?,
serverRegistered: serverRegistered ?? values.serverRegistered,
);
static const Object _keep = Object();
@override
Future<void> setEnabled(bool value) async => values = _with(enabled: value);
@override
Future<void> setOfferDismissed(bool value) async =>
values = _with(offerDismissed: value);
@override
Future<void> setPendingUnregister(bool value) async =>
values = _with(pendingUnregister: value);
@override
Future<void> setLastFid(String? fid) async => values = _with(lastFid: fid);
@override
Future<void> setServerRegistered(bool value) async =>
values = _with(serverRegistered: value);
}
/// 앞에 보이는지를 시험이 바꾼다 (앱 생명주기 대신)
class FakeForeground extends AppForeground {
@override
bool build() => true;
void set(bool value) => state = value;
}
/// 가짜 서버의 새 메일 알림 · 앱 설정 경로
class FakePushBackend {
FakePushBackend(this.server);
final FakeServer server;
/// 받은 등록(PUT 본문 · Bearer)
final List<({Map<String, Object?> body, String? bearer})> puts =
<({Map<String, Object?> body, String? bearer})>[];
final List<RequestOptions> deletes = <RequestOptions>[];
final List<RequestOptions> seen = <RequestOptions>[];
final List<RequestOptions> configRequests = <RequestOptions>[];
/// 두면 그 응답으로 답한다(실패 시험)
FakeResponse? putFailure;
FakeResponse? deleteFailure;
Completer<void>? putGate;
Completer<void>? deleteGate;
/// 앱 설정의 `data` — [configResponse] 가 있으면 그것으로 답한다
Map<String, Object?> config = <String, Object?>{
'minSupportedVersion': <String, Object?>{'android': '1.0.0', 'ios': null},
'pushAvailable': true,
};
FakeResponse? configResponse;
void install() {
server
..on('PUT', ApiPaths.pushRegistration, (RequestOptions options) async {
final Object? data = options.data;
puts.add((
body: data is Map<String, Object?>
? Map<String, Object?>.of(data)
: <String, Object?>{},
bearer: bearerTokenOf(options),
));
await putGate?.future;
return putFailure ??
FakeResponse.ok(<String, Object?>{
'updatedAt': '2026-11-07T05:00:00.000Z',
});
})
..on('DELETE', ApiPaths.pushRegistration, (RequestOptions options) async {
deletes.add(options);
await deleteGate?.future;
return deleteFailure ?? const FakeResponse(204, null, rawBody: '');
})
..on('DELETE', ApiPaths.pushRegistrationUnseen, (RequestOptions options) {
seen.add(options);
return const FakeResponse(204, null, rawBody: '');
})
..on('GET', ApiPaths.appConfig, (RequestOptions options) {
configRequests.add(options);
return configResponse ?? FakeResponse.ok(config);
});
}
}
/// 새 메일 알림 시험대 — 진짜 세션 노티파이어 · 알림 컨트롤러를 가짜 FCM · 알림창 · 서버로 띄운다
/// ([ProviderHarness] 와 달리 기기 등록을 가짜로 바꾸지 않는다)
class PushHarness {
PushHarness();
final FakeServer server = FakeServer();
final MemorySecureStore secure = MemorySecureStore();
final MemoryFirstRunMarker firstRun = MemoryFirstRunMarker();
final ManualClock clock = ManualClock();
final FakePushMessaging messaging = FakePushMessaging();
final FakeNotificationSystem notifications = FakeNotificationSystem();
final MemoryPushPreferences prefs = MemoryPushPreferences();
late final FakePushBackend push = FakePushBackend(server);
late final TokenStore store = TokenStore(
secure: secure,
firstRun: firstRun,
clock: clock.call,
);
late final RecordingNotifier notifier = RecordingNotifier(clock);
/// 시험마다 더하는 덮어쓰기
final List<Override> extra = <Override>[];
List<Override> get overrides => <Override>[
tokenStoreProvider.overrideWithValue(store),
httpClientAdapterProvider.overrideWithValue(server),
clockProvider.overrideWithValue(clock.call),
errorNotifierProvider.overrideWithValue(notifier),
userAgentProvider.overrideWithValue(kTestUserAgent),
appVersionProvider.overrideWithValue('1.0.0'),
pushMessagingProvider.overrideWithValue(messaging),
notificationSystemProvider.overrideWithValue(notifications),
pushPreferencesProvider.overrideWithValue(prefs),
appForegroundProvider.overrideWith(FakeForeground.new),
...extra,
];
ProviderContainer container() =>
ProviderContainer(overrides: overrides, retry: noAutomaticRetry);
/// 보안 저장소에 리프레시 토큰을 두고(앱을 새로 띄운 상태) 준비한다 — 갱신 · me 는 [kTestEmail] 의 세션으로 답한다
Future<void> prepare({String? refreshToken}) async {
if (refreshToken != null) secure.values[kRefreshTokenKey] = refreshToken;
push.install();
server
..on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': tokensJson(2, now: clock.now),
}),
)
..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()))
..on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await store.prepare();
}
}
@@ -0,0 +1,73 @@
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_controller.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_state.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'fakes.dart';
import 'mail_fakes.dart';
import 'mail_harness.dart';
import 'push_fakes.dart';
/// 새 메일 알림 화면 시험대 — 로그인한 앱 전체(메일 시험대)를 띄우고, 세션이 진짜 새 메일 알림 컨트롤러를 부르게 한다.
/// 기기 쪽(FCM · 알림창 · 이 휴대폰의 값)과 서버(`/push-registrations/current` · `/app-config`)는 가짜다.
///
/// 처음 모습: 안드로이드 14 · 알림 권한 허용 · 이 휴대폰에서 켬 · 서버가 보낼 수 있음 — 시험마다 [pump] 전에 바꾼다.
class PushScreenHarness {
PushScreenHarness() {
app.base.recordPush = false;
messaging.current = PushPermission.granted;
app.overrides.addAll(<Override>[
appVersionProvider.overrideWithValue(kPushScreenAppVersion),
pushMessagingProvider.overrideWithValue(messaging),
notificationSystemProvider.overrideWithValue(notifications),
pushPreferencesProvider.overrideWithValue(prefs),
]);
}
final MailAppHarness app = MailAppHarness();
final FakePushMessaging messaging = FakePushMessaging();
final FakeNotificationSystem notifications = FakeNotificationSystem();
final MemoryPushPreferences prefs = MemoryPushPreferences();
late final FakePushBackend push = FakePushBackend(app.server);
FakeServer get server => app.server;
RecordingNotifier get notifier => app.notifier;
/// 지금 알림 상태
PushState get state => app.container.read(pushControllerProvider);
/// 앱을 띄운다 — 세션을 되살리고 알림 등록(FCM · 서버)까지 마친 뒤 [location](없으면 받은편지함)을 그린다
Future<void> pump(WidgetTester tester, {String? location}) async {
push.install();
app.mail.seed(kInboxId, 2);
await app.pump(tester, location: location);
await settle(tester);
}
/// 설정 탭을 연다
Future<void> openSettings(WidgetTester tester) async {
await tester.tap(
find.descendant(
of: find.byType(NavigationBar),
matching: find.text(SettingsTexts.home),
),
);
await settle(tester);
}
}
/// 시험 앱의 판
const String kPushScreenAppVersion = '1.2.0';
/// 그리기 · 가짜 서버 응답이 모두 끝날 때까지 — 서버 응답은 시계가 가야 온다(화면이 가만히 있어도)
Future<void> settle(WidgetTester tester) async {
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
}