generated from ttipo/project-base
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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])는 그대로 탄다
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
/// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user