feat(flutter): 로그인 네 단계 화면을 만든다 (앱 1단계 — App1Login)

주소 · 비밀번호 → 인증 앱 코드 → 새 비밀번호 정하기(임시 비밀번호) → 2단계 켜기(필수일 때)를
세션 노티파이어의 상태로 고른다. 첫 단계만 큰 브랜드 띠, 그다음은 짙은 띠에 [다른 계정으로 로그인].

- 2단계 켜기는 같은 휴대폰이라 QR 대신 [인증 앱에 추가](otpauth — url_launcher 6.3.3,
  canLaunchUrl 은 주소를 로그에 남겨 쓰지 않는다) · 키 [복사] · 코드 칸
- 실패는 칸 아래 · 위쪽 상자 · 전역 알림으로 웹 로그인 틀과 같게(REQ_001 은 코드 · 제한 단계에서
  상자), 칸의 안내와 오류를 함께, 세션 끝은 로그인 화면 상자만
- 비밀번호 · 코드 · 2단계 키는 화면 메모리와 노티파이어의 10분 기억에만(두 제한 단계에서만 쥔다),
  비밀 요청의 실패 예외는 본문을 비운다 — 시험이 모든 프로바이더 값에 비밀이 없음을 본다
- 제한 단계를 보내는 동안 [다른 계정으로 로그인]을 막는다, 주소 칸만 자판 추천(@ 자판)
- 한글 낱말 단위 줄바꿈(KeepAllText), 시험 227건

리뷰 반영: 모르는 제한에서 비밀번호를 쥐지 않음, REQ_001 자리, 안내 · 오류 함께, 주소 칸 자판,
계약 테스트 목록(AUTH_005 · AUTH_008), AUTH_002 를 두 번 알리지 않음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 15:57:39 +09:00
parent dd02b9621a
commit c14580e3a4
54 changed files with 6597 additions and 73 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
void main() {
group('로그인 때 넣은 비밀번호 쥐기', () {
test('쥐고 있는지를 알린다 — 값(비밀번호)이 아니라 참 · 거짓만', () {
final ManualClock clock = ManualClock();
final LoginSecret secret = LoginSecret(clock: clock.call);
final List<bool> seen = <bool>[];
secret.held.addListener(() => seen.add(secret.held.value));
secret.hold('ara.kim@arasystem.it.kr', 'temp-pw');
secret.clear();
expect(seen, <bool>[true, false]);
expect(secret.read(), isNull);
});
test('10분이 지나면 읽을 때 시계로 다시 보고 지운다 — 쥐고 있지 않다고 알린다', () {
final ManualClock clock = ManualClock();
final LoginSecret secret = LoginSecret(clock: clock.call);
secret.hold('ara.kim@arasystem.it.kr', 'temp-pw');
clock.advance(const Duration(minutes: 9, seconds: 59));
expect(secret.read()?.password, 'temp-pw');
clock.advance(const Duration(seconds: 1));
expect(secret.read(), isNull);
expect(secret.held.value, isFalse);
expect(secret.isHeld, isFalse);
});
test('서버가 다시 확인하면(renew) 10분을 그때부터 다시 센다', () {
final ManualClock clock = ManualClock();
final LoginSecret secret = LoginSecret(clock: clock.call);
secret.hold('ara.kim@arasystem.it.kr', 'temp-pw');
clock.advance(const Duration(minutes: 8));
secret.renew();
clock.advance(const Duration(minutes: 8));
expect(secret.read()?.password, 'temp-pw');
});
});
}
@@ -0,0 +1,452 @@
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/features/auth/data/models/auth_session_info.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_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
import '../../support/provider_harness.dart';
/// 세션 노티파이어의 코드 단계 · 제한 단계(새 비밀번호 · 2단계 켜기 · 다음으로 넘기기) — 계약 5 · 6절
void main() {
late ProviderHarness h;
late ProviderContainer container;
Future<void> start() async {
await h.prepare();
container = h.container();
addTearDown(container.dispose);
container.listen(sessionProvider, (_, _) {});
await container.read(sessionProvider.future);
}
SessionNotifier notifier() => container.read(sessionProvider.notifier);
SessionState? current() => container.read(sessionProvider).value;
SessionRestriction? restrictionNow() {
final SessionState? state = current();
return state is SignedIn ? state.info.restriction : null;
}
Map<String, Object?> bodyOf(RequestOptions options) =>
options.data! as Map<String, Object?>;
/// 제한된 세션으로 로그인한다 — 코드 단계를 거치면 [viaCode]
Future<void> signInRestricted(
String restriction, {
bool viaCode = false,
}) async {
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) {
if (viaCode && bodyOf(options)['otpCode'] == null) {
return codeStepResponse();
}
return signedInResponse(1, now: h.clock.now, restriction: restriction);
});
await notifier().signIn(email: kTestEmail, password: 'temp-pw-1');
if (viaCode) await notifier().submitCode('123456');
expect(current(), isA<SignedIn>());
}
setUp(() => h = ProviderHarness());
group('코드 단계', () {
test('코드를 실었는데도 코드를 묻으면 AUTH_006 으로 던지고 코드 단계에 남는다', () async {
await start();
h.server.on('POST', ApiPaths.appLogin, (_) => codeStepResponse());
await notifier().signIn(email: kTestEmail, password: 'pw');
final Object error = await errorOf(notifier().submitCode('123456'));
expect(apiErrorOf(error).code, 'AUTH_006');
expect(current(), const CodeRequired(kTestEmail));
expect(notifier().heldLogin?.password, 'pw');
});
test('그사이 비밀번호가 바뀌었으면(AUTH_004) 주소를 남긴 첫 단계로 돌아간다', () async {
await start();
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) {
return bodyOf(options)['otpCode'] == null
? codeStepResponse()
: FakeResponse.fail(401, 'AUTH_004');
});
await notifier().signIn(email: kTestEmail, password: 'pw');
final Object error = await errorOf(notifier().submitCode('123456'));
expect(apiErrorOf(error).code, 'AUTH_004');
expect(
current(),
const SignedOut(
reason: SignOutReason.passwordRejected,
email: kTestEmail,
),
);
expect(notifier().heldLogin, isNull);
// 칸에 보이는 실패라 알리지 않는다
expect(h.notifier.shownCodes, isEmpty);
});
test('코드 단계를 거쳐 로그인했는지 기억한다 — 새 로그인 · 다른 계정이면 지운다', () async {
await start();
await signInRestricted('passwordChange', viaCode: true);
expect(notifier().passedCodeStep, isTrue);
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await notifier().switchAccount();
expect(notifier().passedCodeStep, isFalse);
await signInRestricted('passwordChange');
expect(notifier().passedCodeStep, isFalse);
});
});
group('새 비밀번호 단계 (changePassword)', () {
test('쥔 임시 비밀번호를 지금 비밀번호로 보내고, 바뀌면 새 비밀번호를 쥔다', () async {
await start();
await signInRestricted('passwordChange', viaCode: true);
h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok());
final RestrictedStepResult result = await notifier().changePassword(
newPassword: 'new-pw-1234',
otpCode: '654321',
);
expect(result, RestrictedStepResult.done);
final RequestOptions sent = h.server
.received('PUT', ApiPaths.password)
.single;
expect(bodyOf(sent), <String, Object?>{
'currentPassword': 'temp-pw-1',
'newPassword': 'new-pw-1234',
'otpCode': '654321',
});
expect(bearerTokenOf(sent), 'access-1');
// 서버가 엔진을 두 번 부른다 — 받기 한도 30초
expect(sent.receiveTimeout, const Duration(seconds: 30));
// 다음 단계(2단계 켜기)의 지금 비밀번호
expect(notifier().heldLogin?.password, 'new-pw-1234');
expect(notifier().heldLoginListenable.value, isTrue);
});
test('쥔 비밀번호를 서버가 거절하면(AUTH_005) 지운다 — 실패 예외에는 본문이 없고 알리지 않는다', () async {
await start();
await signInRestricted('passwordChange');
h.server.on(
'PUT',
ApiPaths.password,
(_) => FakeResponse.fail(400, 'AUTH_005'),
);
final Object error = await errorOf(
notifier().changePassword(newPassword: 'new-pw-1234'),
);
expect(apiErrorOf(error).code, 'AUTH_005');
expect((error as DioException).requestOptions.data, isNull);
expect(notifier().heldLogin, isNull);
expect(notifier().heldLoginListenable.value, isFalse);
expect(h.notifier.shownCodes, isEmpty);
});
test('쥔 비밀번호가 없고 칸의 값도 주지 않으면 보내지 않는다(needsPassword)', () async {
await start();
await signInRestricted('passwordChange');
h.clock.advance(const Duration(minutes: 10));
final RestrictedStepResult result = await notifier().changePassword(
newPassword: 'new-pw-1234',
);
expect(result, RestrictedStepResult.needsPassword);
expect(h.server.received('PUT', ApiPaths.password), isEmpty);
});
test('칸에 넣은 임시 비밀번호로 보낼 수 있다(앱을 다시 열었다)', () async {
await start();
await signInRestricted('passwordChange');
h.clock.advance(const Duration(minutes: 10));
h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok());
final RestrictedStepResult result = await notifier().changePassword(
newPassword: 'new-pw-1234',
currentPassword: 'typed-temp',
);
expect(result, RestrictedStepResult.done);
expect(
bodyOf(h.server.received('PUT', ApiPaths.password).single),
<String, Object?>{
'currentPassword': 'typed-temp',
'newPassword': 'new-pw-1234',
},
);
});
test('보내는 사이 [다른 계정으로 로그인]하면 결과를 쓰지 않는다(stale) — 새 비밀번호를 쥐지 않는다', () async {
await start();
await signInRestricted('passwordChange');
final Completer<void> release = Completer<void>();
h.server.on('PUT', ApiPaths.password, (_) async {
await release.future;
return FakeResponse.ok();
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Future<RestrictedStepResult> changing = notifier().changePassword(
newPassword: 'new-pw-1234',
);
await pumpEventsUntil(
() => h.server.received('PUT', ApiPaths.password).isNotEmpty,
reason: '비밀번호 바꾸기가 서버에 닿기',
);
await notifier().switchAccount();
release.complete();
expect(await changing, RestrictedStepResult.stale);
expect(notifier().heldLogin, isNull);
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
});
});
group('2단계 켜기 (enableTwoFactor)', () {
test('등록 id · 코드 · 쥔 비밀번호를 보내고, 켜면 쥔 비밀번호를 지운다', () async {
await start();
await signInRestricted('twoFactorSetup');
h.server.on(
'POST',
ApiPaths.twoFactorActivation,
(_) => twoFactorStatusResponse(enabled: true),
);
final RestrictedStepResult result = await notifier().enableTwoFactor(
enrollmentId: 'enroll-1',
otpCode: '123456',
);
expect(result, RestrictedStepResult.done);
final RequestOptions sent = h.server
.received('POST', ApiPaths.twoFactorActivation)
.single;
expect(bodyOf(sent), <String, Object?>{
'enrollmentId': 'enroll-1',
'otpCode': '123456',
'currentPassword': 'temp-pw-1',
});
expect(sent.receiveTimeout, const Duration(seconds: 30));
expect(notifier().heldLogin, isNull);
});
test('쥔 비밀번호를 서버가 거절하면(AUTH_005) 지운다 — 화면이 지금 비밀번호 칸을 보인다', () async {
await start();
await signInRestricted('twoFactorSetup');
h.server.on(
'POST',
ApiPaths.twoFactorActivation,
(_) => FakeResponse.fail(400, 'AUTH_005'),
);
final Object error = await errorOf(
notifier().enableTwoFactor(enrollmentId: 'enroll-1', otpCode: '123456'),
);
expect(apiErrorOf(error).code, 'AUTH_005');
expect((error as DioException).requestOptions.data, isNull);
expect(notifier().heldLoginListenable.value, isFalse);
expect(h.notifier.shownCodes, isEmpty);
});
test('코드가 틀리면(AUTH_006) 쥔 비밀번호는 그대로다(코드만 다시 넣는다)', () async {
await start();
await signInRestricted('twoFactorSetup');
h.server.on(
'POST',
ApiPaths.twoFactorActivation,
(_) => FakeResponse.fail(400, 'AUTH_006'),
);
await errorOf(
notifier().enableTwoFactor(enrollmentId: 'enroll-1', otpCode: '000000'),
);
expect(notifier().heldLogin?.password, 'temp-pw-1');
});
});
group('모르는 제한 — 로그인 때 넣은 비밀번호를 쥐지 않는다(웹 onSignedIn 과 같다)', () {
test('모르는 제한으로 로그인하면 쓸 단계가 없어 쥐지 않는다', () async {
await start();
await signInRestricted('somethingNew');
expect(restrictionNow(), SessionRestriction.unknown);
expect(notifier().heldLogin, isNull);
expect(notifier().heldLoginListenable.value, isFalse);
});
test('갱신 응답으로 제한이 모르는 제한이 되면 쥔 비밀번호를 지운다', () async {
await start();
await signInRestricted('passwordChange');
expect(notifier().heldLogin, isNotNull);
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) =>
refreshResponse(2, now: h.clock.now, restriction: 'somethingNew'),
);
await container.read(authRepositoryProvider).refresh();
await pumpEventsUntil(
() => restrictionNow() == SessionRestriction.unknown,
reason: '갱신 응답의 제한 반영',
);
expect(notifier().heldLogin, isNull);
expect(notifier().heldLoginListenable.value, isFalse);
});
test('다시 맞추기(AUTH_010)로 모르는 제한이 되면 쥔 비밀번호를 지운다', () async {
await start();
await signInRestricted('twoFactorSetup');
h.server.on(
'GET',
'/mailboxes',
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'somethingNew')),
);
await errorOf(container.read(dioProvider).get<Object?>('/mailboxes'));
expect(restrictionNow(), SessionRestriction.unknown);
expect(notifier().heldLogin, isNull);
});
test('제한이 새 비밀번호 → 2단계 켜기로 바뀌면 쥔 비밀번호는 그대로다(다음 단계가 쓴다)', () async {
await start();
await signInRestricted('passwordChange');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) =>
refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'),
);
await container.read(authRepositoryProvider).refresh();
await pumpEventsUntil(
() => restrictionNow() == SessionRestriction.twoFactorSetup,
reason: '갱신 응답의 제한 반영',
);
expect(notifier().heldLogin?.password, 'temp-pw-1');
});
});
group('다음 단계로 넘기기 (finishRestrictedStep)', () {
test('새 토큰의 세션 칸으로 다음 제한(2단계 켜기)으로 옮긴다', () async {
await start();
await signInRestricted('passwordChange');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) =>
refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'),
);
expect(
await notifier().finishRestrictedStep(),
RestrictedStepResult.done,
);
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
expect(h.store.accessToken, 'access-2');
// 2단계 켜기가 지금 비밀번호로 쓴다
expect(notifier().heldLogin, isNotNull);
expect(h.push.registered, 0);
});
test('제한이 풀리면 메일로 — 쥔 비밀번호를 지우고 알림 기기를 등록한다', () async {
await start();
await signInRestricted('twoFactorSetup');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => refreshResponse(2, now: h.clock.now),
);
expect(
await notifier().finishRestrictedStep(),
RestrictedStepResult.done,
);
expect(current(), isA<SignedIn>());
expect(restrictionNow(), isNull);
expect(notifier().heldLogin, isNull);
expect(h.push.registered, 1);
});
test('갱신이 장애면 세션 확인(me)의 지금 값으로 정한다', () async {
await start();
await signInRestricted('twoFactorSetup');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
expect(
await notifier().finishRestrictedStep(),
RestrictedStepResult.done,
);
expect(restrictionNow(), isNull);
expect(current(), isA<SignedIn>());
expect(h.notifier.shownCodes, isEmpty);
});
test('갱신도 세션 확인도 못 하면 갱신의 실패로 던지고 그 단계에 머문다', () async {
await start();
await signInRestricted('twoFactorSetup');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline);
final Object error = await errorOf(notifier().finishRestrictedStep());
expect(apiErrorOf(error).code, 'MAIL_001');
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
// 화면이 위쪽 상자에 보인다 — 알리지 않는다
expect(h.notifier.shownCodes, isEmpty);
});
test('갱신이 세션을 끝냈으면(401) stale — 로그인 화면으로 옮긴다', () async {
await start();
await signInRestricted('twoFactorSetup');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
expect(
await notifier().finishRestrictedStep(),
RestrictedStepResult.stale,
);
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝');
expect(current(), const SignedOut(reason: SignOutReason.expired));
});
});
}
@@ -180,7 +180,14 @@ void main() {
h.clock.advance(const Duration(minutes: 10));
await notifier().submitCode('123456');
expect(current(), const SignedOut(reason: SignOutReason.codeTimedOut));
// 첫 단계가 주소를 남기고 위쪽 상자에 까닭을 보인다
expect(
current(),
const SignedOut(
reason: SignOutReason.codeTimedOut,
email: 'ara.kim@arasystem.it.kr',
),
);
expect(h.server.received('POST', ApiPaths.appLogin), hasLength(1));
});
@@ -350,7 +357,8 @@ void main() {
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝 알림');
expect(current(), const SignedOut(reason: SignOutReason.expired));
expect(h.notifier.shownCodes, <String>['AUTH_002']);
// 로그인 화면의 위쪽 상자가 알린다 — 스낵바는 띄우지 않는다
expect(h.notifier.shownCodes, isEmpty);
});
test(
@@ -0,0 +1,159 @@
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart';
import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart';
import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
import '../../support/provider_harness.dart';
void main() {
group('2단계 등록 모델', () {
test('응답을 읽고, toString 은 키 · otpauth 주소를 가린다', () {
final TwoFactorEnrollment? enrollment =
TwoFactorEnrollment.tryParse(<String, Object?>{
'enrollmentId': 'enroll-1',
'secret': kTestSecret,
'otpauthUrl': kTestOtpauthUrl,
'expiresAt': '2026-11-05T03:10:00.000Z',
});
expect(enrollment?.enrollmentId, 'enroll-1');
expect(enrollment?.secret, kTestSecret);
expect(enrollment?.expiresAt, DateTime.utc(2026, 11, 5, 3, 10));
expect(enrollment.toString(), isNot(contains(kTestSecret)));
expect(enrollment.toString(), isNot(contains('otpauth')));
});
test('칸이 비었거나 otpauth 주소가 아니면 읽지 않는다', () {
expect(TwoFactorEnrollment.tryParse(null), isNull);
expect(
TwoFactorEnrollment.tryParse(<String, Object?>{
'enrollmentId': 'enroll-1',
'secret': kTestSecret,
'otpauthUrl': 'https://evil.example/otp',
}),
isNull,
);
expect(
TwoFactorEnrollment.tryParse(<String, Object?>{
'enrollmentId': '',
'secret': kTestSecret,
'otpauthUrl': kTestOtpauthUrl,
}),
isNull,
);
});
test('상태는 isEnabled 만 본다 — 없으면 읽지 않는다', () {
expect(
TwoFactorStatus.tryParse(<String, Object?>{
'isEnabled': true,
})?.isEnabled,
isTrue,
);
expect(TwoFactorStatus.tryParse(<String, Object?>{}), isNull);
});
});
group('[인증 앱에 추가]', () {
test('otpauth 주소를 브라우저가 아닌 앱으로 연다', () async {
final List<(Uri, LaunchMode)> calls = <(Uri, LaunchMode)>[];
final AuthenticatorLauncher launcher = AuthenticatorLauncher(
launch: (Uri url, LaunchMode mode) async {
calls.add((url, mode));
return true;
},
);
expect(await launcher.open(kTestOtpauthUrl), isTrue);
expect(calls.single.$1.toString(), kTestOtpauthUrl);
expect(calls.single.$2, LaunchMode.externalNonBrowserApplication);
});
test('받을 앱이 없어 플러그인이 던지면(문장에 키가 든다) 삼키고 false', () async {
final AuthenticatorLauncher launcher = AuthenticatorLauncher(
launch: (Uri url, LaunchMode mode) async => throw PlatformException(
code: 'ACTIVITY_NOT_FOUND',
message: 'No Activity found to handle intent { $url }',
),
);
expect(await launcher.open(kTestOtpauthUrl), isFalse);
});
test('otpauth 주소가 아니면 열지 않는다', () async {
int calls = 0;
final AuthenticatorLauncher launcher = AuthenticatorLauncher(
launch: (Uri url, LaunchMode mode) async {
calls++;
return true;
},
);
expect(await launcher.open('https://evil.example/otp'), isFalse);
expect(calls, 0);
});
});
group('문의 연락처', () {
test('한 번 받으면 다시 묻지 않고, 다듬은 한 줄을 둔다', () async {
final ProviderHarness h = ProviderHarness();
await h.prepare();
final ProviderContainer container = h.container();
addTearDown(container.dispose);
h.server.on(
'GET',
ApiPaths.supportContact,
(_) => FakeResponse.ok(<String, Object?>{
'text': ' IT 담당 홍길동\n02-000-0000 ',
}),
);
final SupportContactNotifier notifier = container.read(
supportContactProvider.notifier,
);
await Future.wait(<Future<void>>[
notifier.ensureLoaded(),
notifier.ensureLoaded(),
]);
await notifier.ensureLoaded();
expect(container.read(supportContactProvider), 'IT 담당 홍길동 02-000-0000');
expect(h.server.received('GET', ApiPaths.supportContact), hasLength(1));
// 로그인 없이 부른다 — 토큰을 싣지 않는다
expect(
bearerTokenOf(h.server.received('GET', ApiPaths.supportContact).single),
isNull,
);
});
test('받지 못하면 알리지 않고 null — 다음에 다시 묻는다', () async {
final ProviderHarness h = ProviderHarness();
await h.prepare();
final ProviderContainer container = h.container();
addTearDown(container.dispose);
h.server.on('GET', ApiPaths.supportContact, (_) => FakeResponse.offline);
final SupportContactNotifier notifier = container.read(
supportContactProvider.notifier,
);
await notifier.ensureLoaded();
expect(container.read(supportContactProvider), isNull);
expect(h.notifier.shownCodes, isEmpty);
h.server.on(
'GET',
ApiPaths.supportContact,
(_) => FakeResponse.ok(<String, Object?>{'text': '사내 메신저 @it'}),
);
await notifier.ensureLoaded();
expect(container.read(supportContactProvider), '사내 메신저 @it');
});
});
}