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
@@ -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);
});
});
}