feat(flutter): 앱 로그인 · 토큰 갱신 · 모듈 라우터 기반을 만든다

앱 1단계의 바탕이다(계약 1절 "앱 로그인" — 서버는 다음 차례, 지금은 가짜 서버로 시험한다).

- 토큰: 리프레시 토큰은 보안 저장소에만, 액세스 토큰은 메모리에만. 첫 실행 표시가 없으면 남은 토큰을
  지우고, 안드로이드 백업을 끈다. 보안 저장소 일은 차례로 한다
- 인터셉터: 401 이면 갱신 한 번(동시 401 묶음) 뒤 다시 보내기, AUTH_010 은 me 를 다시 받아 그 단계로,
  미리 갱신 30초 전 · 만료는 응답 Date 로, 세션 세대가 바뀐 뒤의 응답은 조용히 버린다,
  비밀이 든 요청의 실패 예외는 본문 · Bearer 를 지운다
- 세션: 로그인 · 코드 · 새 비밀번호 · 2단계 켜기 단계의 상태와 늦은 결과 버리기(버린 로그인의 토큰은
  조용히 logout), 로그아웃은 메모리부터 비운다, 유예 응답이면 세션 끝 + 조용한 logout
- 모듈 틀: go_router StatefulShellRoute 와 아래 탭 막대 — 모듈 목록은 웹 app-modules.ts 와 짝,
  첫 판은 [메일 · 설정]. 모듈끼리 import 하지 않는다
- Riverpod 자동 다시 하기를 끈다, Pretendard 1.3.9(OFL), 확정 글자 크기, 시험 126건
- 판은 Flutter 3.41.9(Dart 3.11.5) — pubspec 의 sdk 하한과 lock 을 함께

리뷰 세 번 반영(AUTH_010 다시 보내기 · 늦은 응답 · 저장 실패 · 로그아웃 차례 · 비밀이 실린 객체 등).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 11:11:28 +09:00
parent 5823f600aa
commit 35fcf5e4ef
82 changed files with 7339 additions and 324 deletions
@@ -0,0 +1,663 @@
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/storage/token_store.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/provider_harness.dart';
void main() {
late ProviderHarness h;
late ProviderContainer container;
Future<ProviderContainer> start({String? refreshToken}) async {
await h.prepare(refreshToken: refreshToken);
container = h.container();
addTearDown(container.dispose);
// 세션을 듣기 시작해 되살리기를 돌린다
container.listen(sessionProvider, (_, _) {});
return container;
}
Future<SessionState?> settled() async {
try {
return await container.read(sessionProvider.future);
} on Object {
return null;
}
}
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;
}
void refreshGives(int n, {String? restriction}) {
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(restriction: restriction),
'tokens': tokensJson(n, now: h.clock.now),
}),
);
}
/// 코드 단계로 답하는 로그인
FakeResponse codeStep() => FakeResponse.ok(<String, Object?>{
'status': 'twoFactorRequired',
'email': null,
'name': null,
'restriction': null,
'twoFactorDeadline': null,
'tokens': null,
});
/// 로그인을 마친 응답 ([n] 번째 토큰)
FakeResponse signedInWith(int n, {String? restriction}) =>
FakeResponse.ok(<String, Object?>{
'status': 'signedIn',
...sessionJson(restriction: restriction),
'tokens': tokensJson(n, now: h.clock.now),
});
List<RequestOptions> logouts() =>
h.server.received('POST', ApiPaths.appLogout);
setUp(() => h = ProviderHarness());
group('되살리기', () {
test('리프레시 토큰이 없으면 서버에 묻지 않고 로그아웃 상태다', () async {
await start();
expect(await settled(), const SignedOut());
expect(h.server.requests, isEmpty);
});
test('리프레시 토큰이 있으면 갱신 뒤 me 로 세션을 되살린다', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')),
);
final SessionState? state = await settled();
expect(state, isA<SignedIn>());
expect(restrictionNow(), SessionRestriction.passwordChange);
expect(
bearerTokenOf(h.server.received('GET', ApiPaths.me).single),
'access-2',
);
// 되살리기는 알리지 않는다
expect(h.notifier.shownCodes, isEmpty);
});
test('갱신이 거절되면 토큰을 지우고 알림 없이 로그아웃 상태로 시작한다', () async {
await start(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
expect(await settled(), const SignedOut());
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(h.notifier.shownCodes, isEmpty);
});
test('연결이 끊겨 되살리지 못하면 세션을 두고 오류 상태다 — [다시 시도]는 곧바로 다시 묻는다', () async {
await start(refreshToken: 'refresh-1');
h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
expect(await settled(), isNull);
expect(container.read(sessionProvider).hasError, isTrue);
expect(h.secure.values[kRefreshTokenKey], 'refresh-1');
// 5초 안에 눌러도 방금의 실패를 다시 쓰지 않는다
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
notifier().retry();
expect(await settled(), isA<SignedIn>());
expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(2));
});
});
group('로그인', () {
test('2단계 인증을 켠 직원은 코드 단계로 가고, 코드와 비밀번호를 함께 보내 로그인한다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) {
final Map<String, Object?> body = options.data as Map<String, Object?>;
return body['otpCode'] == null ? codeStep() : signedInWith(1);
});
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw-1234',
);
expect(current(), const CodeRequired('ara.kim@arasystem.it.kr'));
await notifier().submitCode('123456');
expect(current(), isA<SignedIn>());
expect(
h.server.received('POST', ApiPaths.appLogin).last.data,
<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'password': 'pw-1234',
'otpCode': '123456',
},
);
expect(h.secure.values[kRefreshTokenKey], 'refresh-1');
expect(h.store.accessToken, 'access-1');
// 제한 없이 로그인을 마치면 쥐고 있던 비밀번호를 지운다
expect(notifier().heldLogin, isNull);
});
test('코드 단계에 10분 넘게 머물면 보내지 않고 첫 단계로 돌아간다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => codeStep());
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
expect(notifier().heldLogin?.password, 'pw');
h.clock.advance(const Duration(minutes: 10));
await notifier().submitCode('123456');
expect(current(), const SignedOut(reason: SignOutReason.codeTimedOut));
expect(h.server.received('POST', ApiPaths.appLogin), hasLength(1));
});
test('제한된 세션으로 로그인하면 새 비밀번호 단계가 쓸 비밀번호를 쥐고 있는다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => signedInWith(1, restriction: 'passwordChange'),
);
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'temp-pw',
);
expect(restrictionNow(), SessionRestriction.passwordChange);
expect(notifier().heldLogin?.password, 'temp-pw');
});
test('로그인 화면이 칸에 보이는 실패(AUTH_004)는 알리지 않고 코드로 던진다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.fail(401, 'AUTH_004'),
);
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'wrong'),
);
expect(apiErrorOf(error).code, 'AUTH_004');
expect(h.notifier.shownCodes, isEmpty);
expect(current(), const SignedOut());
});
test('응답 모양이 계약과 다르면 DioException(SYS_001)으로 던진다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.ok(<String, Object?>{'status': 'signedIn'}),
);
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'),
);
expect(error, isA<DioException>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(h.store.hasSession, isFalse);
});
});
group('늦게 온 결과 버리기 (시도 세대)', () {
test('[다른 계정으로 로그인] 뒤에 온 코드 단계의 로그인 결과는 쓰지 않고 그 세션을 조용히 끝낸다', () async {
await start();
await settled();
final Completer<void> release = Completer<void>();
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) async {
final Map<String, Object?> body = options.data as Map<String, Object?>;
if (body['otpCode'] == null) return codeStep();
await release.future;
return signedInWith(1);
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
final Future<void> code = notifier().submitCode('123456');
await pumpEventsUntil(
() => h.server.received('POST', ApiPaths.appLogin).length == 2,
reason: '코드 요청이 서버에 닿기',
);
await notifier().switchAccount();
release.complete();
await code;
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃');
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-1',
});
expect(bearerTokenOf(logouts().single), 'access-1');
});
test('로그아웃 뒤에 온 로그인 결과도 버린다', () async {
await start();
await settled();
final Completer<void> release = Completer<void>();
h.server.on('POST', ApiPaths.appLogin, (_) async {
await release.future;
return signedInWith(1);
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Future<void> signIn = notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw',
);
await pumpEventsUntil(
() => h.server.received('POST', ApiPaths.appLogin).isNotEmpty,
reason: '로그인 요청이 서버에 닿기',
);
await notifier().signOut();
release.complete();
await signIn;
expect(current(), const SignedOut());
expect(h.store.accessToken, isNull);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃');
expect(bearerTokenOf(logouts().single), 'access-1');
});
test('제한을 다시 맞추는 사이 로그아웃하면 결과를 쓰지 않는다(stale)', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
final Completer<void> release = Completer<void>();
int meCalls = 0;
h.server.on('GET', ApiPaths.me, (_) async {
meCalls++;
if (meCalls == 1) return FakeResponse.ok(sessionJson());
await release.future;
return FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup'));
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isA<SignedIn>());
final Future<RestrictionCheck> check = notifier().recheck();
await pumpEventsUntil(() => meCalls == 2, reason: '세션 확인이 서버에 닿기');
await notifier().signOut();
release.complete();
expect(await check, RestrictionCheck.stale);
expect(current(), const SignedOut());
});
});
group('로그인한 뒤', () {
Future<void> signedIn({String? restriction}) async {
await start(refreshToken: 'refresh-1');
refreshGives(2, restriction: restriction);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: restriction)),
);
expect(await settled(), isA<SignedIn>());
}
test('갱신이 거절되면(다른 곳에서 끊음) 로그인 화면으로 간다', () 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(() => current() is SignedOut, reason: '세션 끝 알림');
expect(current(), const SignedOut(reason: SignOutReason.expired));
expect(h.notifier.shownCodes, <String>['AUTH_002']);
});
test(
'AUTH_010 — me 에 제한이 있으면 그 단계로 옮기고 원래 요청은 다시 보내지 않으며 알리지 않는다',
() async {
await signedIn();
h.server.on(
'GET',
'/mailboxes',
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup')),
);
final Object error = await errorOf(
container.read(dioProvider).get<Object?>('/mailboxes'),
);
expect(apiErrorOf(error).code, 'AUTH_010');
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
expect(h.server.received('GET', '/mailboxes'), hasLength(1));
// 제한이 있으면 갱신하지 않는다 — 되살리기 때의 한 번뿐
expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1));
expect(h.notifier.shownCodes, isEmpty);
},
);
test('AUTH_010 — me 에 제한이 없으면(옛 토큰) 갱신한 뒤 원래 요청을 한 번 다시 보낸다', () async {
await signedIn();
int mailboxCalls = 0;
h.server.on('GET', '/mailboxes', (_) {
mailboxCalls++;
return mailboxCalls == 1
? FakeResponse.fail(403, 'AUTH_010')
: FakeResponse.ok(<Object?>['inbox']);
});
refreshGives(3);
final Response<Object?> response = await container
.read(dioProvider)
.get<Object?>('/mailboxes');
expect(response.data, <Object?>['inbox']);
final List<RequestOptions> sent = h.server.received('GET', '/mailboxes');
expect(sent.map(bearerTokenOf), <String?>['access-2', 'access-3']);
expect(h.notifier.shownCodes, isEmpty);
expect(current(), isA<SignedIn>());
});
test('갱신 응답의 제한이 바뀌면 화면 상태도 바꾼다', () async {
await signedIn();
h.clock.advance(const Duration(hours: 2));
refreshGives(3, restriction: 'twoFactorSetup');
h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok(<Object?>[]));
await container.read(dioProvider).get<Object?>('/mailboxes');
await pumpEventsUntil(
() => restrictionNow() == SessionRestriction.twoFactorSetup,
reason: '갱신 응답의 제한 반영',
);
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
});
test('제한이 풀리면 쥐고 있던 비밀번호를 바로 지운다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => signedInWith(1, restriction: 'passwordChange'),
);
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'temp-pw',
);
expect(notifier().heldLogin, isNotNull);
// 새 비밀번호를 정한 뒤 곧바로 갱신 — 응답에 제한이 없다
refreshGives(2);
await container.read(authRepositoryProvider).refresh();
await pumpEventsUntil(
() => restrictionNow() == null && current() is SignedIn,
reason: '제한이 풀린 갱신 반영',
);
expect(notifier().heldLogin, isNull);
});
test('로그아웃하면 토큰을 지우고 서버 세션을 리프레시 토큰과 Bearer 로 끝낸다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(h.store.accessToken, isNull);
expect(h.store.signedOutByUser, isTrue);
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-2',
});
expect(bearerTokenOf(logouts().single), 'access-2');
});
test('보안 저장소를 읽지 못해도 이 기기의 토큰은 지우고 Bearer 로 서버 세션을 끝낸다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
h.secure.failReads = true;
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.store.accessToken, isNull);
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(logouts().single.data, <String, Object?>{});
expect(bearerTokenOf(logouts().single), 'access-2');
});
test('로그아웃 요청이 닿지 않아도 이 기기는 로그아웃 상태다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.offline);
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.notifier.shownCodes, isEmpty);
});
});
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>());
}
int refreshCalls() => h.server.received('POST', ApiPaths.appRefresh).length;
test('me 가 401(액세스 토큰이 끝남)이면 갱신해 보고, 새 토큰에 제한이 없으면 renewed', () async {
await signedIn();
h.server.on('GET', ApiPaths.me, (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.fail(401, 'AUTH_001')
: FakeResponse.ok(sessionJson());
});
refreshGives(3);
expect(await notifier().recheck(), RestrictionCheck.renewed);
expect(refreshCalls(), 2);
expect(h.store.accessToken, 'access-3');
expect(current(), isA<SignedIn>());
expect(restrictionNow(), isNull);
});
test('me 401 뒤 갱신도 401 이면 stale — 세션 끝이 로그인 화면으로 옮긴다', () async {
await signedIn();
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
expect(await notifier().recheck(), RestrictionCheck.stale);
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝');
expect(current(), const SignedOut(reason: SignOutReason.expired));
expect(h.store.hasSession, isFalse);
});
test('me 가 장애(503)면 unknown — 상태와 토큰은 그대로', () async {
await signedIn();
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
expect(await notifier().recheck(), RestrictionCheck.unknown);
expect(current(), isA<SignedIn>());
expect(refreshCalls(), 1);
expect(h.store.hasSession, isTrue);
});
});
group('저장 · 로그아웃의 경합', () {
test('로그인 토큰을 저장하는 사이 로그아웃하면 토큰이 남지 않고 그 세션도 끝낸다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => signedInWith(1));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Completer<void> gate = Completer<void>();
h.secure.writeGate = gate;
final Future<void> signIn = notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw',
);
await pumpEventsUntil(
() => h.secure.writeCalls == 1,
reason: '토큰 쓰기가 시작',
);
final Future<void> signOut = notifier().signOut();
gate.complete();
await signOut;
await signIn;
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.store.accessToken, isNull);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '서버 세션 끝내기');
expect(
logouts().every(
(RequestOptions r) =>
(r.data! as Map<String, Object?>)['refreshToken'] == 'refresh-1',
),
isTrue,
);
});
test(
'로그인 토큰을 보안 저장소에 두지 못하면 이 기기의 토큰을 지우고 그 세션을 끝낸 뒤 SYS_001 로 던진다',
() async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => signedInWith(1));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
h.secure.failWrites = true;
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'),
);
expect(error, isA<DioException>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.store.accessToken, isNull);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '서버 세션 끝내기');
expect(bearerTokenOf(logouts().single), 'access-1');
},
);
test('로그아웃은 토큰과 상태를 먼저 지우고, 알림 토큰 지우기를 기다리지 않는다', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isA<SignedIn>());
// 알림 토큰 지우기가 끝나지 않아도 로그아웃은 끝난다(짧은 한도 · 실패는 삼킨다)
h.push.unregisterGate = Completer<void>();
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.push.unregistered, 1);
expect(h.push.hadSessionAtUnregister, isFalse);
expect(logouts(), hasLength(1));
});
test('되살리기가 장애로 멈춘 채 [다른 계정으로 로그인]을 눌러도 남은 리프레시 토큰을 지운다', () async {
await start(refreshToken: 'refresh-1');
h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isNull);
await notifier().switchAccount();
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-1',
});
});
test('되살리는 사이 로그아웃하면 늦게 온 되살리기 결과가 로그아웃을 덮지 않는다', () async {
final Completer<void> release = Completer<void>();
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) async {
await release.future;
return FakeResponse.ok(sessionJson());
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await pumpEventsUntil(
() => h.server.received('GET', ApiPaths.me).isNotEmpty,
reason: '되살리기의 me 가 서버에 닿기',
);
await notifier().signOut();
release.complete();
expect(await settled(), const SignedOut());
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
});
});
}