fix(flutter): 토큰 갱신 429 로 로그아웃되지 않게, 갱신 뒤 사진 다시 보내기

리뷰에서 나온 앱 쪽 문제를 고친다.
- 토큰 갱신이 429 를 받으면 세션을 끊던 문제 — 거절(401·400)만 세션을 끊고, 429 는 REQ_001·그 밖은 SYS_001 로 알린다
  (서버는 갱신을 IP 당 분당 10회로 막아 통신사 망처럼 여럿이 한 IP 를 쓰면 실제로 생긴다)
- 액세스 토큰이 만료된 뒤 사진을 올리면 다시 보낼 때 FormData 가 이미 쓰여 실패하던 문제 — 복사본으로 다시 보낸다
- 에러 사전 계약 테스트에 앱이 직접 쓰는 도메인 코드(FILE·DIARY·CARD·SCHED)를 더한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 22:57:34 +09:00
parent 3f84d7b747
commit 043e14a3fa
3 changed files with 60 additions and 7 deletions
+28
View File
@@ -169,6 +169,34 @@ void main() {
expect(t.session.hasSession, isTrue);
});
test('갱신이 너무 잦아 막히면(429) 세션은 두고 요청 과다(REQ_001)로 알린다', () async {
final t = build((o) => o.path == kRefreshPath
? (status: 429, body: _fail('REQ_001'))
: (status: 401, body: _fail('AUTH_001')));
final error = await errorOf(t.client.dio.get<Object?>('/pets'));
expect(error.code, 'REQ_001');
expect(t.session.hasSession, isTrue, reason: '한 IP 를 여럿이 쓰는 망에서도 생기므로 로그아웃시키지 않는다');
});
test('사진 올리기(FormData)도 토큰을 갱신한 뒤 다시 보낼 수 있다', () async {
final t = build((o) {
if (o.path == kRefreshPath) return (status: 201, body: _newTokens());
return _auth(o) == 'Bearer new-access-1'
? (status: 201, body: _ok({'id': 'f1'}))
: (status: 401, body: _fail('AUTH_001'));
});
final res = await t.client.dio.post<Map<String, dynamic>>(
'/files',
data: FormData.fromMap({'file': MultipartFile.fromBytes(const [1, 2, 3], filename: 'a.png')}),
);
expect(res.data, {'id': 'f1'});
expect(reported, isEmpty);
});
test('로그인 전(토큰 없음)의 401 은 그대로 AUTH_001 이다', () async {
final t = build((o) => (status: 401, body: _fail('AUTH_001')), tokens: null);
+15
View File
@@ -40,6 +40,21 @@ void main() {
}
});
test('화면이 분기하거나 같은 문구로 안내하는 도메인 코드가 사전에 존재한다', () {
// 이름이 바뀌면 조용히 SYS_001 문구로 떨어지므로, 앱 코드가 직접 쓰는 도메인 코드를 지킨다
// (checklist 3절 — 클라이언트가 그 코드로 분기하면 목록에 추가한다)
const domainUsedByClient = [
'FILE_001', 'FILE_002', // 사진 올리기
'DIARY_001', 'DIARY_002', 'DIARY_003', 'DIARY_004', 'DIARY_005', // 다이어리 책장·하루 한 번·초대
'CARD_001', 'CARD_002', 'CARD_003', // 명함 한도·받기
'SCHED_001', 'SCHED_002', 'SCHED_003', 'SCHED_004', 'SCHED_005', // 일정 연동
];
for (final code in domainUsedByClient) {
expect(ErrorLexicon.contains(code), isTrue, reason: '$code 누락');
}
});
test('모든 코드는 DOMAIN_NNN 네이밍 규약을 따른다', () {
final pattern = RegExp(r'^[A-Z]+_\d{3}$');