feat(flutter): 메일 읽기 화면을 만든다 (App1Reader)

머리는 앱 화면, 본문은 스크립트를 끈 WebView 에 앱이 감싼 CSP 문서로
보인다. 받은 HTML 은 서버 정화 뒤 앱이 한 번 더 허용 목록으로 다시 쓰고,
외부 이미지는 [이미지 보기] 뒤 앱이 Dio 로 받아 data: 로 넣는다(토큰이
WebView 에 가지 않는다).

- 읽음 · 중요 · 이동 · 스팸 · 삭제 · 영구 삭제, 붙잡힌 메일의 보내기 취소 ·
  예약 취소는 목록과 같은 규칙. 응답은 그 요청이 바꾼 칸만 지금 값에 덮는다
- 첨부는 앱 임시 폴더(cacheDir/downloads)에 받아 FileProvider + ACTION_VIEW
  로 연다(파일 여는 플러그인 없이). 열 형식은 확장자 먼저, 웹 문서 · svg ·
  설치 파일 · 인증서는 받지 않는다
- 응답 인터셉터가 바이트 · 스트림 실패 본문(64KB 까지)에서 코드를 읽고,
  뜻밖의 실패에도 handler.next 로 끝낸다
- 리뷰 H1: 같은 그림을 수백 번 쓴 메일이 문서를 GB 단위로 키우던 것 —
  문서에 싣는 그림 글을 나온 횟수까지 세어 10M 자에서 멈춘다
- 리뷰 M1: 짧고 깊은 본문을 화면 isolate 에서 정화하던 것 — 길이와 중첩
  비용으로 고르고(6,144자 · 비용 5,000), 그 밖은 Isolate.run
- 새 의존성: webview_flutter 4.14.1 · webview_flutter_android 4.12.0 ·
  html 0.15.7(+ csslib 1.0.2) — 배포 2주 넘음. path_provider · mime 은
  lock 에 있던 판을 직접 의존으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 22:26:09 +09:00
parent ef23afc94c
commit a37045a553
51 changed files with 8325 additions and 48 deletions
@@ -0,0 +1,411 @@
import 'dart:typed_data';
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
import 'package:flutter_test/flutter_test.dart';
/// PNG 머리 — 그림 크기만 큰 시험 그림을 만든다
const List<int> kTestPng = <int>[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
];
/// 받은 메일 본문의 두 번째 정화 · WebView 문서 (웹 `mail-html.spec.ts` 의 결)
void main() {
String clean(String html, {bool imagesShown = false}) =>
sanitizeMailHtml(html, imagesShown: imagesShown).fragment;
group('정화 — 태그', () {
test('스크립트 · 스타일 · 틀 · 개체 · SVG · 수식은 내용째 버린다', () {
final String out = clean(
'<p>안녕</p><script>alert(1)</script><style>p{color:red}</style>'
'<iframe src="https://evil.example"></iframe><object data="x"></object>'
'<embed src="x"><svg><script>alert(2)</script><text>svg 글</text></svg>'
'<math><mi>x</mi></math><noscript><img src=x onerror=alert(3)></noscript>'
'<template><b>숨은 글</b></template>',
);
expect(out, '<p>안녕</p>');
});
test('폼 · 단추 · 모르는 태그는 태그만 지우고 글은 남긴다', () {
final String out = clean(
'<form action="https://evil.example"><button>보내기</button><o:p>본문</o:p></form>'
'<input value="숨은 값"><select><option>항목</option></select>',
);
expect(out, '보내기본문');
});
test('글 · 표 · 목록 · 인용은 남긴다', () {
final String out = clean(
'<blockquote><table><tbody><tr><td colspan="2">칸</td></tr></tbody></table>'
'<ul><li>하나</li></ul><br><hr></blockquote>',
);
expect(
out,
'<blockquote><table><tbody><tr><td colspan="2">칸</td></tr></tbody></table>'
'<ul><li>하나</li></ul><br><hr></blockquote>',
);
});
test('주석은 지우고 글은 이스케이프한다', () {
expect(
clean('<p><!-- 주석 -->a &lt;b&gt; &amp; c</p>'),
'<p>a &lt;b&gt; &amp; c</p>',
);
});
test('깊은 중첩도 스택이 넘치지 않고 끝난다(되풀이로 돈다)', () {
const int depth = 1500;
final String html = '${'<div>' * depth}끝${'</div>' * depth}';
final String out = clean(html);
expect(out, '${'<div>' * depth}끝${'</div>' * depth}');
});
test('맨 위 요소가 아주 많아도 문서로 읽어 고르게 끝난다', () {
final String html = '<span>a</span>' * 40000;
final String out = clean(html);
expect(out.length, html.length);
});
});
group('정화 전 비용 (중첩)', () {
test('태그마다 그때의 깊이를 더한다 — 빈 요소만 깊이를 늘리지 않는다', () {
expect(htmlNestingCost('<p>a</p>', limit: 1000), 0 + 1);
expect(
htmlNestingCost('<div><p>a</p></div>', limit: 1000),
0 + 1 + 2 + 1,
);
expect(
htmlNestingCost('<div><br><img src="x"><br/></div>', limit: 1000),
0 + 1 + 1 + 1 + 1,
);
expect(htmlNestingCost('글만 있다 &lt;div&gt;', limit: 1000), 0);
});
test('빈 요소가 아닌 <x/> 는 여는 태그로 센다 — 파서가 / 를 무시한다', () {
const int depth = 100;
expect(
htmlNestingCost('<div/>' * depth, limit: 1 << 30),
htmlNestingCost('<div>' * depth, limit: 1 << 30),
);
expect(
htmlNestingCost('<div><span/></div>', limit: 1000),
// span 이 열린 채라 </div> 는 깊이 2 에서 세고 닫지 않는다(맨 위가 span)
0 + 1 + 2,
);
});
test('주석을 건너뛰지 않는다 — 주석 안의 태그도 센다(서버가 주석을 지운다)', () {
expect(
htmlNestingCost('<!-- <div><div> --><p>a</p>', limit: 1000),
0 + 1 + 2 + 3,
);
expect(htmlNestingCost('<!--><div><div>', limit: 1000), 0 + 1);
expect(htmlNestingCost('<!-- x --!><div><div>', limit: 1000), 0 + 1);
const int depth = 300;
final String hidden = '<!--${'<div>' * depth}-->';
expect(
htmlNestingCost(hidden, limit: 1 << 30),
htmlNestingCost('<div>' * depth, limit: 1 << 30),
);
// 문서 형식 선언 · 엉뚱한 주석은 파서처럼 다음 > 까지 건너뛴다
expect(
htmlNestingCost('<!DOCTYPE html><?x?><div><div>', limit: 1000),
0 + 1,
);
});
test('맞지 않는 닫는 태그는 깊이를 줄이지 않는다 — 파서가 무시하는 </span> 으로 깊이를 덜 세지 않게', () {
const int n = 1000;
final String html = '${'<div>' * n}${'</span>' * n}${'<div>' * n}';
// 여는 div 2,000 개는 0 ~ 1,999 깊이에서, </span> 1,000 개는 깊이 1,000 에서 센다
const int expected = (2 * n - 1) * (2 * n) ~/ 2 + n * n;
expect(htmlNestingCost(html, limit: 1 << 30), expected);
// 맞는 닫는 태그는 줄인다 — 같은 깊이를 다시 연다
expect(
htmlNestingCost('<div></div><div></div>', limit: 1000),
0 + 1 + 0 + 1,
);
expect(
htmlNestingCost('<DIV><P>a</p></Div>', limit: 1000),
0 + 1 + 2 + 1,
);
});
test('한도를 넘으면 그 자리에서 멈춘다 — 깊은 본문은 정화하지 않고 글자 본문을 보인다', () {
const int depth = 20000;
final String deep = '${'<div>' * depth}끝${'</div>' * depth}';
expect(
htmlNestingCost(deep, limit: kMaxHtmlNestingCost),
greaterThan(kMaxHtmlNestingCost),
);
final SanitizedMailBody body = mailBodyFragment(
htmlBody: deep,
textBody: '글자 본문',
imagesShown: true,
);
expect(body.htmlOmitted, isTrue);
expect(body.fragment, '<div class="mail-text">글자 본문</div>');
expect(body.proxiedTokens, isEmpty);
});
test('서버 한도(2,000,000자)보다 긴 서식 본문도 글자 본문으로', () {
final SanitizedMailBody body = mailBodyFragment(
htmlBody: '<p>${'a' * kMaxSanitizedHtmlLength}</p>',
textBody: null,
imagesShown: false,
);
expect(body.htmlOmitted, isTrue);
expect(body.fragment, '<div class="mail-text"></div>');
});
});
group('정화 — 속성', () {
test('이벤트 처리기 · data-* · id · target · 외부 자원 속성은 지운다', () {
final String out = clean(
'<div id="x" onclick="alert(1)" data-ara-large-files="1" style="color:#123456" '
'background="https://t.example/b.png"><a href="https://ok.example" target="_blank" '
'rel="opener" ping="https://t.example">링크</a></div>',
);
expect(
out,
'<div style="color:#123456"><a href="https://ok.example">링크</a></div>',
);
});
test('링크는 http · https · mailto 만 남긴다', () {
expect(clean('<a href="javascript:alert(1)">a</a>'), '<a>a</a>');
expect(clean('<a href=" JavaScript:alert(1)">a</a>'), '<a>a</a>');
expect(clean('<a href="/api/messages">a</a>'), '<a>a</a>');
expect(clean('<a href="file:///etc/passwd">a</a>'), '<a>a</a>');
expect(clean('<a href="intent://x#Intent;end">a</a>'), '<a>a</a>');
expect(
clean('<a href="mailto:kim@example.com">a</a>'),
'<a href="mailto:kim@example.com">a</a>',
);
expect(
clean('<a href="HTTPS://Example.com/a?b=1&amp;c=2">a</a>'),
'<a href="HTTPS://Example.com/a?b=1&amp;c=2">a</a>',
);
});
test('속성 값의 따옴표 · 꺾쇠는 늘 이스케이프한다', () {
expect(
clean('<span title="a&quot;b>c<d" style="x">t</span>'),
'<span title="a&quot;b&gt;c&lt;d" style="x">t</span>',
);
});
test(
'그림은 data: 그림(PNG · JPEG · GIF · WebP)만 — SVG · 바깥 주소 · 상대 주소는 주소를 지운다',
() {
const String png = 'data:image/png;base64,iVBORw0KGgo=';
expect(clean('<img src="$png" alt="서명">'), '<img src="$png" alt="서명">');
expect(
clean('<img src="data:image/svg+xml;base64,PHN2Zz4=">'),
'<img>',
);
expect(
clean('<img src="https://t.example/p.gif" width="1">'),
'<img width="1">',
);
expect(clean('<img src="/api/mail-images/abc">'), '<img>');
expect(clean('<img srcset="https://t.example/a.png 2x">'), '<img>');
},
);
});
group('외부 이미지(중계 주소)', () {
test('외부 이미지를 보일 때만 중계 주소를 남기고 표를 문서 차례 · 겹치지 않게 모은다', () {
final SanitizedMailBody body = sanitizeMailHtml(
'<img src="/api/mail-images/tok_A" alt="a"><img src="/api/mail-images/tok-B">'
'<img src="/api/mail-images/tok_A"><img src="/api/mail-images/../x">'
'<img src="https://t.example/p.png">',
imagesShown: true,
);
expect(body.proxiedTokens, <String>['tok_A', 'tok-B']);
expect(
body.fragment,
'<img src="/api/mail-images/tok_A" alt="a"><img src="/api/mail-images/tok-B">'
'<img src="/api/mail-images/tok_A"><img><img>',
);
});
test('한 통에 100개까지만 받는다 — 넘는 그림은 주소를 지운다', () {
final String html = <String>[
for (int n = 0; n < kMaxProxiedImages + 5; n++)
'<img src="/api/mail-images/t$n">',
].join();
final SanitizedMailBody body = sanitizeMailHtml(html, imagesShown: true);
expect(body.proxiedTokens, hasLength(kMaxProxiedImages));
expect(body.fragment, endsWith('<img><img><img><img><img>'));
});
test('받은 그림으로 바꾸고, 받지 못한 그림은 주소를 지운다 — 글 속의 같은 모양에는 닿지 않는다', () {
final SanitizedMailBody body = sanitizeMailHtml(
'<p>&lt;img src="/api/mail-images/t1"</p>'
'<img src="/api/mail-images/t1" alt="x"><img src="/api/mail-images/t2">',
imagesShown: true,
);
final String resolved = resolveProxiedImages(
body.fragment,
<String, String>{'t1': 'data:image/png;base64,AAAA'},
);
expect(
resolved,
'<p>&lt;img src="/api/mail-images/t1"</p>'
'<img src="data:image/png;base64,AAAA" alt="x"><img>',
);
});
/// [token] 을 [count] 번 쓴 본문의 정화 조각
String repeated(String token, int count) => sanitizeMailHtml(
'<img src="/api/mail-images/$token">' * count,
imagesShown: true,
).fragment;
/// 실린 그림 수 · 지운 그림 수
({int shown, int hidden}) countImages(String resolved) => (
shown: '<img src="data:'.allMatches(resolved).length,
hidden: '<img>'.allMatches(resolved).length,
);
test(
'같은 그림이 여러 번 나오면 나온 만큼 센다 — 1MB 그림 1,000번도 문서가 한도 안이고 앞쪽 몇 개만 보인다',
() {
final Uint8List bytes = Uint8List(1024 * 1024)
..setRange(0, kTestPng.length, kTestPng);
final String data = imageDataUri('image/png', bytes);
final String fragment = repeated('big', 1000);
final String resolved = resolveProxiedImages(fragment, <String, String>{
'big': data,
});
final int fits = kMailImagesDocumentMaxLength ~/ data.length;
expect(fits, 7);
expect(countImages(resolved), (shown: fits, hidden: 1000 - fits));
expect(
resolved.length,
lessThanOrEqualTo(kMailImagesDocumentMaxLength + fragment.length),
);
// 앞쪽부터 싣는다
expect(resolved, startsWith('<img src="data:image/png;base64,'));
expect(resolved, endsWith('<img><img>'));
},
);
test('1KB 아이콘이 같은 표로 300번 나오면 모두 보인다', () {
final String icon = 'data:image/png;base64,${'A' * 1024}';
final String resolved = resolveProxiedImages(
repeated('icon', 300),
<String, String>{'icon': icon},
);
expect(countImages(resolved), (shown: 300, hidden: 0));
});
test(
'서로 다른 그림이 섞이면 문서 차례로 싣고, 넘는 자리부터는 뒤의 작은 그림도 지운다 — 받지 못한 그림은 세지 않는다',
() {
final SanitizedMailBody body = sanitizeMailHtml(
'<img src="/api/mail-images/a"><img src="/api/mail-images/b">'
'<img src="/api/mail-images/gone"><img src="/api/mail-images/a">'
'<img src="/api/mail-images/b"><img src="/api/mail-images/a">'
'<img src="/api/mail-images/b">',
imagesShown: true,
);
const String a = 'data:image/png;base64,AAAAAAAAAAAAAAAAAA'; // 40자
const String b = 'data:image/gif;base64,BB'; // 24자
// 셋째 a(40)가 넘친다 — 마지막 b(24)는 남은 자리(30)에 들지만 넘는 자리 뒤라 지운다
final String resolved = resolveProxiedImages(
body.fragment,
<String, String>{'a': a, 'b': b},
maxTotalLength: 40 + 24 + 40 + 24 + 30,
);
expect(
resolved,
'<img src="$a"><img src="$b"><img><img src="$a"><img src="$b"><img><img>',
);
},
);
});
group('정화할 곳 (그 자리 · 다른 isolate)', () {
MailBodyBuildPlace placeOf(String? html, [String? text]) =>
mailBodyBuildPlaceOf(htmlBody: html, textBody: text);
test('짧고 얕은 서식 본문 · 짧은 글자 본문만 그 자리에서', () {
expect(placeOf('<p>안녕하세요</p>'), MailBodyBuildPlace.inline);
expect(placeOf(null, '글자 본문'), MailBodyBuildPlace.inline);
expect(placeOf(' ', '공백뿐인 서식 본문은 글자 본문'), MailBodyBuildPlace.inline);
expect(
placeOf('<p>${'가' * (kMailBodyInlineMaxLength - 8)}</p>'),
MailBodyBuildPlace.inline,
);
});
test('길면 다른 isolate 에서 — 서식 본문 · 글자 본문 모두', () {
expect(
placeOf('<p>${'가' * kMailBodyInlineMaxLength}</p>'),
MailBodyBuildPlace.isolate,
);
expect(
placeOf(null, '가' * kMailBodyInlineMaxTextLength),
MailBodyBuildPlace.isolate,
);
});
test('짧아도 깊으면 다른 isolate 에서 — 2,800단 · 30KB 본문, 200단 · 2KB 본문', () {
final String deep = '${'<div>' * 2800}끝${'</div>' * 2800}';
expect(deep.length, lessThan(32 * 1024));
expect(placeOf(deep), MailBodyBuildPlace.isolate);
final String short = '${'<div>' * 200}끝${'</div>' * 200}';
expect(short.length, lessThan(kMailBodyInlineMaxLength));
expect(
htmlNestingCost(short, limit: 1 << 30),
greaterThan(kMailBodyInlineMaxCost),
);
expect(placeOf(short), MailBodyBuildPlace.isolate);
final String shallow = '${'<div>' * 40}끝${'</div>' * 40}';
expect(placeOf(shallow), MailBodyBuildPlace.inline);
});
});
group('본문 조각 · 문서', () {
test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () {
final SanitizedMailBody text = mailBodyFragment(
htmlBody: ' \n ',
textBody: '<b>굵게</b>\n둘째 줄',
imagesShown: false,
);
expect(
text.fragment,
'<div class="mail-text">&lt;b&gt;굵게&lt;/b&gt;\n둘째 줄</div>',
);
expect(displayedHtmlBody(null), isNull);
expect(displayedHtmlBody('<p>a</p>'), '<p>a</p>');
});
test('문서는 CSP 를 맨 앞에 두고 바깥 요청 · 스크립트를 막는다', () {
final String document = buildMailDocument('<p>본문</p>');
expect(
kMailDocumentCsp,
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
);
expect(
document,
startsWith(
'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
'<meta http-equiv="Content-Security-Policy" content="$kMailDocumentCsp">',
),
);
expect(
document,
contains('<meta name="referrer" content="no-referrer">'),
);
expect(document, contains('<body><p>본문</p></body>'));
expect(document, isNot(contains('<script')));
});
});
}
@@ -9,6 +9,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da
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_reader_page.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
@@ -76,10 +77,17 @@ void main() {
MailListPage currentPage(WidgetTester tester) =>
tester.widget<MailListPage>(find.byType(MailListPage));
/// 위에 쌓아 연 전체 화면(읽기 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
String pushedLocation(WidgetTester tester) => GoRouterState.of(
tester.element(find.byType(PlaceholderPage).last),
).uri.toString();
/// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
String pushedLocation(WidgetTester tester) {
final Finder reader = find.byType(MessageReaderPage);
return GoRouterState.of(
tester.element(
reader.evaluate().isNotEmpty
? reader.last
: find.byType(PlaceholderPage).last,
),
).uri.toString();
}
group('목록 줄', () {
testWidgets('받은편지함 — 보낸 사람 · 시각 · 제목 · 미리 보기, 안 읽음은 굵게 · 첨부 · 중요', (
@@ -122,7 +130,7 @@ void main() {
expect(tester.getSemantics(avatarOf('채용 일정')).label, '고르기: 채용 일정');
});
testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 읽음 처리는 하지 않는다', (
testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 목록은 읽음 처리를 하지 않는다(읽기 화면이 한다)', (
WidgetTester tester,
) async {
h.mail.seed(kInboxId, 2);
@@ -132,9 +140,16 @@ void main() {
await tester.pumpAndSettle();
expect(pushedLocation(tester), MailRoutes.message('m1'));
expect(find.text(MailTexts.message), findsOneWidget);
expect(find.byType(MessageReaderPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
// 여러 통 바꾸기는 없다 — 읽음은 읽기 화면의 한 통 바꾸기
expect(h.mail.patches, isEmpty);
expect(
h.mail.singlePatches.map(
(({String id, Map<String, Object?> body}) p) => p.id,
),
<String>['m1'],
);
});
testWidgets('임시보관함 — 받는 사람을 보이고, 누르면 쓰기 화면에서 이어 쓴다', (
@@ -0,0 +1,506 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:arasystem_app/core/files/file_names.dart';
import 'package:arasystem_app/core/utils/file_size.dart';
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
import 'package:arasystem_app/features/mail/domain/mail_links.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/message_reader_view.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/mail_fakes.dart';
/// 읽기 화면의 규칙 — 순수 함수 (링크 · mailto · 쓰기 화면 주소 · 파일 이름 · 크기 · 그림 판정 · 안내 · 머리 표기 · 메뉴)
void main() {
MessageDetail detailOf(
Map<String, Object?> overrides, {
String id = 'm1',
String mailboxId = kInboxId,
}) {
final MessageDetail? detail = MessageDetail.tryParse(<String, Object?>{
...messageJson(id, mailboxId: mailboxId),
'cc': <Object?>[],
'bcc': <Object?>[],
'htmlBody': null,
'textBody': '본문',
'hasBlockedImages': false,
'attachments': <Object?>[],
...overrides,
});
return detail!;
}
group('본문 링크 · 탐색 판정', () {
test('처음 넣은 문서(about:blank)만 연다 — 아래 틀은 열지 않는다', () {
expect(
mailNavigationOf('about:blank', isMainFrame: true),
isA<OpenInitialDocument>(),
);
expect(
mailNavigationOf('about:blank', isMainFrame: false),
isA<IgnoreNavigation>(),
);
expect(
mailNavigationOf('https://ok.example', isMainFrame: false),
isA<IgnoreNavigation>(),
);
});
test('http · https 는 바깥 앱으로, mailto 는 쓰기 화면으로, 그 밖은 무시', () {
final MailNavigation web = mailLinkOf('https://www.example.com/a?b=1');
expect(web, isA<OpenExternally>());
expect((web as OpenExternally).uri.host, 'www.example.com');
expect(mailLinkOf('HTTP://Example.com'), isA<OpenExternally>());
expect(mailLinkOf('mailto:kim@example.com'), isA<OpenCompose>());
for (final String url in <String>[
'javascript:alert(1)',
'file:///data/data/kr.it.arasystem.app/x',
'content://kr.it.arasystem.app.downloads/downloads/a',
'intent://scan/#Intent;scheme=zxing;end',
'tel:01012345678',
'about:srcdoc',
'/api/messages',
'https://',
'https://ok.example/\u0000x',
'',
'https://ok.example/${'a' * kMaxExternalLinkLength}',
]) {
expect(mailLinkOf(url), isA<IgnoreNavigation>(), reason: url);
}
});
test('mailto — 받는 사람 · 참조 · 숨은참조 · 제목 · 본문을 풀고, 틀린 주소 · 겹친 주소는 뺀다', () {
final MailNavigation link = mailLinkOf(
'mailto:kim@example.com,%20lee@example.com,bad,KIM@example.com'
'?cc=park@example.com&bcc=choi@example.com&subject=%ED%9A%8C%EC%9D%98%0A%EC%9D%BC%EC%A0%95'
'&body=%EC%95%88%EB%85%95%0A%ED%95%98%EC%84%B8%EC%9A%94&to=yoon@example.com',
);
final ComposePrefill prefill = (link as OpenCompose).prefill;
expect(prefill.to, <String>[
'kim@example.com',
'lee@example.com',
'yoon@example.com',
]);
expect(prefill.cc, <String>['park@example.com']);
expect(prefill.bcc, <String>['choi@example.com']);
expect(prefill.subject, '회의 일정');
expect(prefill.body, '안녕\n하세요');
});
test('mailto — 제목 · 본문은 한도에서 자른다', () {
final ComposePrefill prefill = composePrefillOfMailto(
Uri.parse(
'mailto:?subject=${'가' * 300}&body=${'나' * (kComposeMaxPrefillBody + 10)}',
),
)!;
expect(prefill.to, isEmpty);
expect(prefill.subject.length, kComposeMaxSubject);
expect(prefill.body.length, kComposeMaxPrefillBody);
expect(composePrefillOfMailto(Uri.parse('https://x.example')), isNull);
});
});
group('쓰기 화면 주소', () {
test('답장 · 전체 답장 · 전달 · 이어 쓰기는 웹과 같은 쿼리다', () {
expect(MailRoutes.composeReply('m 1'), '/mail/compose?replyTo=m+1');
expect(
MailRoutes.composeReply('m1', all: true),
'/mail/compose?replyTo=m1&all=1',
);
expect(MailRoutes.composeForward('m1'), '/mail/compose?forward=m1');
expect(MailRoutes.composeDraft('d1'), '/mail/compose?draft=d1');
});
test('쿼리 → 여는 까닭 (이어 쓰기 → 답장 → 전달 차례)', () {
expect(composeIntentOf(<String, String>{}), isA<NewComposeIntent>());
final ComposeIntent reply = composeIntentOf(
Uri.parse(MailRoutes.composeReply('m1', all: true)).queryParameters,
);
expect(reply, isA<ReplyComposeIntent>());
expect((reply as ReplyComposeIntent).messageId, 'm1');
expect(reply.all, isTrue);
expect(
composeIntentOf(<String, String>{'replyTo': 'm1'}),
isA<ReplyComposeIntent>().having(
(ReplyComposeIntent r) => r.all,
'all',
isFalse,
),
);
expect(
composeIntentOf(<String, String>{'forward': 'm2', 'draft': 'd1'}),
isA<DraftComposeIntent>(),
);
expect(
composeIntentOf(<String, String>{'forward': 'm2'}),
isA<ForwardComposeIntent>(),
);
});
});
group('받은 파일 이름', () {
test('경로 문자는 밑줄로, 제어 · 방향 제어 · 보이지 않는 글자는 지운다', () {
expect(safeFileName('../../etc/passwd'), '_.._etc_passwd');
expect(safeFileName(r'a\b:c*d?e"f<g>h|i.txt'), 'a_b_c_d_e_f_g_h_i.txt');
expect(safeFileName('보고\u0000서\u001F.pdf'), '보고서.pdf');
expect(safeFileName('invoice\u202Efdp.exe'), 'invoicefdp.exe');
expect(safeFileName('​견적.xlsx'), '견적.xlsx');
});
test('앞뒤 공백 · 점을 지우고, 비면 기본 이름', () {
expect(safeFileName(' .hidden.txt. '), 'hidden.txt');
expect(safeFileName('..'), kFallbackFileName);
expect(safeFileName(' \u0007 '), kFallbackFileName);
});
test('UTF-8 200바이트 안으로 줄이고 확장자는 남긴다', () {
final String name = safeFileName('${'가' * 120}.docx');
expect(utf8.encode(name).length, lessThanOrEqualTo(kMaxFileNameBytes));
expect(name, endsWith('.docx'));
expect(name, startsWith('가가가'));
final String noExtension = safeFileName('a' * 400);
expect(noExtension.length, kMaxFileNameBytes);
});
});
group('파일 크기 표기 (웹 formatFileSize 와 같다)', () {
test('B · KB(정수) · MB(10 미만은 소수 한 자리)', () {
expect(formatFileSize(0), '0 B');
expect(formatFileSize(1023), '1023 B');
expect(formatFileSize(248 * 1024), '248 KB');
expect(formatFileSize(1024 * 1024 - 1), '1 MB');
expect(formatFileSize((1.2 * 1024 * 1024).round()), '1.2 MB');
expect(formatFileSize(3 * 1024 * 1024), '3 MB');
expect(formatFileSize(38 * 1024 * 1024), '38 MB');
expect(formatFileSize(-1), '');
});
});
group('외부 이미지 판정', () {
test('앞 바이트로 PNG · JPEG · GIF · WebP 만 그림으로 본다', () {
expect(sniffImageType(Uint8List.fromList(kTinyPng)), 'image/png');
expect(
sniffImageType(Uint8List.fromList(<int>[0xFF, 0xD8, 0xFF, 0xE0])),
'image/jpeg',
);
expect(
sniffImageType(Uint8List.fromList(ascii.encode('GIF89a...'))),
'image/gif',
);
expect(
sniffImageType(
Uint8List.fromList(
ascii.encode('RIFF\u0000\u0000\u0000\u0000WEBPVP8 '),
),
),
'image/webp',
);
expect(
sniffImageType(
Uint8List.fromList(utf8.encode('<svg onload=alert(1)>')),
),
isNull,
);
expect(sniffImageType(Uint8List(0)), isNull);
});
test('그림 하나 5MB · 합계 10MB 를 넘으면 넣지 않는다', () {
final Uint8List png = Uint8List.fromList(kTinyPng);
expect(
acceptMailImage(png, usedBytes: 0),
startsWith('data:image/png;base64,'),
);
expect(
acceptMailImage(png, usedBytes: kMailImagesTotalMaxBytes - 1),
isNull,
);
final Uint8List big = Uint8List(kMailImageMaxBytes + 1)
..setAll(0, kTinyPng);
expect(acceptMailImage(big, usedBytes: 0), isNull);
});
});
group('본문 안내 (웹 mailBodyNotices 와 같다)', () {
test('생략 → 잘림 차례, 보여 줄 본문이 없으면 잘림은 뺀다', () {
expect(
mailBodyNoticesOf(
textBody: '글',
isHtmlBodyOmitted: true,
isBodyTruncated: true,
),
<MailBodyNotice>[MailBodyNotice.htmlOmitted, MailBodyNotice.truncated],
);
expect(
mailBodyNoticesOf(
textBody: ' ',
isHtmlBodyOmitted: true,
isBodyTruncated: true,
),
<MailBodyNotice>[MailBodyNotice.unavailable],
);
expect(
mailBodyNoticesOf(
textBody: null,
isHtmlBodyOmitted: false,
isBodyTruncated: true,
),
<MailBodyNotice>[MailBodyNotice.truncated],
);
expect(
mailBodyNoticesOf(
textBody: null,
isHtmlBodyOmitted: false,
isBodyTruncated: false,
),
isEmpty,
);
});
});
group('머리 표기', () {
final DateTime now = DateTime(2026, 11, 5, 15);
test('시각 — 오늘 · 어제 · 올해 · 그 전 (기기 시간대)', () {
expect(formatReaderTime(DateTime(2026, 11, 5, 10, 42), now), '오전 10:42');
expect(
formatReaderTime(DateTime(2026, 11, 4, 16, 20), now),
'어제 오후 4:20',
);
expect(
formatReaderTime(DateTime(2026, 3, 1, 9, 5), now),
'3월 1일 오전 9:05',
);
expect(
formatReaderTime(DateTime(2025, 10, 24, 12, 30), now),
'2025. 10. 24. 오후 12:30',
);
expect(
formatReaderFullDate(DateTime(2026, 11, 4, 16, 20)),
'2026년 11월 4일 (수) 오후 4:20',
);
});
test('받는 사람 줄 — 지금 직원이면 "나", 받는 사람 · 참조 · 숨은참조를 합쳐 센다', () {
final MessageDetail toMe = detailOf(<String, Object?>{
'to': <Object?>[addressJson('김아라', 'Ara.Kim@arasystem.it.kr')],
});
expect(
recipientSummaryOf(toMe, myEmail: 'ara.kim@arasystem.it.kr'),
'받는 사람 나',
);
final MessageDetail withCc = detailOf(<String, Object?>{
'to': <Object?>[addressJson('김아라', 'ara.kim@arasystem.it.kr')],
'cc': <Object?>[
addressJson('박준호', 'junho@arasystem.it.kr'),
addressJson('김아라', 'ara.kim@arasystem.it.kr'),
],
});
expect(
recipientSummaryOf(withCc, myEmail: 'ara.kim@arasystem.it.kr'),
'받는 사람 나 외 1명',
);
final MessageDetail others = detailOf(<String, Object?>{
'to': <Object?>[
addressJson('박준호', 'junho@arasystem.it.kr'),
<String, Object?>{'name': null, 'email': 'x@example.com'},
],
'bcc': <Object?>[addressJson('최', 'choi@example.com')],
});
expect(
recipientSummaryOf(others, myEmail: 'ara.kim@arasystem.it.kr'),
'받는 사람 박준호 외 2명',
);
expect(
recipientSummaryOf(detailOf(const <String, Object?>{}), myEmail: null),
MailReaderTexts.noRecipients,
);
});
test('펼친 칸의 줄 — 이름이 없거나 주소와 같으면 주소만', () {
expect(readerAddressLine(const MailAddress(email: 'a@x.kr', name: '김')), (
name: '김',
email: 'a@x.kr',
));
expect(readerAddressLine(const MailAddress(email: 'a@x.kr')), (
name: 'a@x.kr',
email: null,
));
expect(
readerAddressLine(const MailAddress(email: 'a@x.kr', name: 'a@x.kr')),
(name: 'a@x.kr', email: null),
);
});
test('붙잡힌 메일의 안내 — 예약은 시각, 보내기 취소를 기다리면 "곧 보낼 메일입니다."', () {
expect(
pendingNoticeOf(
PendingSend(
sendAt: DateTime(2026, 11, 7, 9),
kind: PendingSendKind.scheduled,
),
),
'11월 7일 (토) 오전 9:00 에 보낼 메일입니다.',
);
expect(
pendingNoticeOf(
PendingSend(
sendAt: DateTime(2026, 11, 7),
kind: PendingSendKind.undo,
),
),
MailReaderTexts.sendingNotice,
);
});
});
group('삭제 · 메뉴', () {
const Mailbox trash = Mailbox(
id: kTrashId,
name: '휴지통',
role: MailboxRole.trash,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
);
test('휴지통에만 든 메일만 영구 삭제다 — 다른 메일함에도 있으면 휴지통으로 옮기기', () {
MessageSummary inBoxes(List<String> ids) =>
detailOf(<String, Object?>{'mailboxIds': ids}).summary;
expect(deletesPermanently(inBoxes(<String>[kTrashId]), trash), isTrue);
expect(
deletesPermanently(inBoxes(<String>[kTrashId, kInboxId]), trash),
isFalse,
);
expect(deletesPermanently(inBoxes(<String>[kInboxId]), trash), isFalse);
expect(deletesPermanently(inBoxes(<String>[kTrashId]), null), isFalse);
});
MailboxesState ready() => MailboxesState(
status: MailboxesStatus.ready,
items: <Mailbox>[
for (final Map<String, Object?> json in standardMailboxes())
Mailbox.tryParse(json)!,
],
);
test('메뉴 — 읽음 · 중요는 늘, 스팸메일함에서는 스팸 아님, 붙잡힌 메일은 이동 · 스팸이 없다', () {
final List<String> normal = <String>[
for (final ReaderMenuItem item in readerMenuOf(
detailOf(<String, Object?>{'isRead': true}),
ready(),
))
item.label,
];
expect(normal, <String>[
MailReaderTexts.markUnread,
MailReaderTexts.flag,
MailReaderTexts.move,
MailReaderTexts.reportSpam,
]);
final List<String> junk = <String>[
for (final ReaderMenuItem item in readerMenuOf(
detailOf(<String, Object?>{
'isFlagged': true,
'mailboxIds': <String>[kJunkId],
}),
ready(),
))
item.label,
];
expect(junk, <String>[
MailReaderTexts.markRead,
MailReaderTexts.unflag,
MailReaderTexts.move,
MailReaderTexts.notSpam,
]);
final List<String> held = <String>[
for (final ReaderMenuItem item in readerMenuOf(
detailOf(<String, Object?>{
'mailboxIds': <String>[kSentId],
'pendingSend': pendingJson(DateTime.utc(2026, 11, 7)),
}),
ready(),
))
item.label,
];
expect(held, <String>[MailReaderTexts.markRead, MailReaderTexts.flag]);
// 메일함 목록을 받기 전에는 옮길 곳 · 스팸메일함을 몰라 이동 · 스팸이 없다
expect(
readerMenuOf(
detailOf(const <String, Object?>{}),
const MailboxesState(),
),
hasLength(2),
);
});
});
group('메일 한 통 읽기 (MessageDetail)', () {
test('9단계 뒤 칸이 없으면 거짓 · 빈 목록으로 읽고, 본문 그림은 첨부 목록에서 뺀다', () {
final MessageDetail detail = detailOf(<String, Object?>{
'attachments': <Object?>[
attachmentJson('a1'),
attachmentJson(
'a2',
name: 'sig.png',
type: 'image/png',
isInline: true,
),
],
'largeFiles': <Object?>[
<String, Object?>{'fileId': 'f1', 'name': '큰파일.zip', 'size': 3},
<String, Object?>{'fileId': 1},
],
});
expect(detail.imagesShown, isFalse);
expect(detail.isBodyTruncated, isFalse);
expect(
detail.listedAttachments.map((MessageAttachment a) => a.id),
<String>['a1'],
);
expect(detail.largeFiles.single.name, '큰파일.zip');
});
test('모양이 틀리면 null — 첨부 · 본문 칸의 형식', () {
expect(detailOfOrNull(<String, Object?>{'htmlBody': 3}), isNull);
expect(
detailOfOrNull(<String, Object?>{
'attachments': <Object?>[
<String, Object?>{'id': 'a'},
],
}),
isNull,
);
expect(detailOfOrNull(<String, Object?>{'cc': 'x'}), isNull);
});
});
group('첨부 받기 진행', () {
test('비율 — 전체를 모르면 null', () {
expect(const AttachmentDownload(received: 64, total: 100).percent, 64);
expect(const AttachmentDownload(received: 64, total: 100).fraction, 0.64);
expect(const AttachmentDownload(received: 5).percent, isNull);
expect(const AttachmentDownload(received: 500, total: 100).percent, 100);
});
});
}
MessageDetail? detailOfOrNull(Map<String, Object?> overrides) =>
MessageDetail.tryParse(<String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'textBody': '본문',
...overrides,
});
@@ -1,12 +1,19 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/file_fakes.dart';
import '../../support/mail_fakes.dart';
/// 메일 저장소 — 계약 2절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다)
@@ -202,4 +209,293 @@ void main() {
await errorOf(repository.mailState());
expect(h.notifier.shownCodes, <String>['MAIL_001']);
});
group('읽기 화면', () {
Map<String, Object?> detailJson({
Map<String, Object?> extra = const <String, Object?>{},
}) => <String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'cc': <Object?>[],
'bcc': <Object?>[],
'htmlBody': '<p>본문</p>',
'textBody': '본문',
'hasBlockedImages': true,
'imagesShown': false,
'attachments': <Object?>[attachmentJson('a1')],
...extra,
};
test('메일 한 통 — images=show 는 [이미지 보기]에만 싣고, 처음 받기의 실패는 알리지 않는다', () async {
h.server.on(
'GET',
MailApiPaths.message('m1'),
(_) => FakeResponse.ok(detailJson()),
);
final DateTime serverTime = DateTime.utc(2026, 11, 6, 9);
h.server.serverTime = serverTime;
final ({MessageDetail detail, DateTime? serverTime}) first =
await repository.getMessage('m1');
await repository.getMessage('m1', showImages: true, silent: false);
expect(first.detail.hasBlockedImages, isTrue);
expect(first.detail.attachments.single.id, 'a1');
expect(first.serverTime, serverTime);
expect(h.server.requests[0].queryParameters, isEmpty);
expect(h.server.requests[1].queryParameters, <String, Object?>{
kMailImagesQuery: kMailImagesShow,
});
h.server.on(
'GET',
MailApiPaths.message('m1'),
(_) => FakeResponse.fail(404, 'RES_001'),
);
expect(
apiErrorOf(await errorOf(repository.getMessage('m1'))).code,
'RES_001',
);
expect(h.notifier.shownCodes, isEmpty);
expect(
apiErrorOf(
await errorOf(
repository.getMessage('m1', showImages: true, silent: false),
),
).code,
'RES_001',
);
expect(h.notifier.shownCodes, <String>['RES_001']);
});
test(
'붙잡은 발송 취소 — DELETE /scheduled-messages/{id} 의 draftId, 이미 보냈으면 MAIL_014 를 알린다',
() async {
h.server.on(
'DELETE',
MailApiPaths.scheduledMessage('s/1'),
(_) => FakeResponse.ok(<String, Object?>{'draftId': 's/1'}),
);
expect(
MailApiPaths.scheduledMessage('s/1'),
'/scheduled-messages/s%2F1',
);
expect(await repository.cancelPendingSend('s/1'), 's/1');
h.server.on(
'DELETE',
MailApiPaths.scheduledMessage('s/1'),
(_) => FakeResponse.fail(409, 'MAIL_014'),
);
expect(
apiErrorOf(await errorOf(repository.cancelPendingSend('s/1'))).code,
'MAIL_014',
);
expect(h.notifier.shownCodes, <String>['MAIL_014']);
},
);
test('외부 이미지 — 바이트를 받고 실패는 알리지 않는다, 한도를 넘게 오면 끊는다', () async {
h.server.on(
'GET',
MailApiPaths.mailImage('tok'),
(_) => FakeResponse.bytes(kTinyPng, contentType: 'image/png'),
);
expect(await repository.fetchMailImage('tok', maxBytes: 1024), kTinyPng);
expect(bearerTokenOf(h.server.requests.last), 'access-1');
expect(
(await errorOf(repository.fetchMailImage('tok', maxBytes: 4))
as DioException)
.type,
DioExceptionType.cancel,
);
h.server.on(
'GET',
MailApiPaths.mailImage('tok'),
(_) => FakeResponse.fail(404, 'RES_001'),
);
expect(
apiErrorOf(
await errorOf(repository.fetchMailImage('tok', maxBytes: 1024)),
).code,
'RES_001',
);
expect(h.notifier.shownCodes, isEmpty);
});
test('첨부 받기 — 조각마다 이어 쓰고 진행을 알린다(길이 머리가 없으면 전체는 null)', () async {
final StreamController<Uint8List> chunks = StreamController<Uint8List>();
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a 1'),
(_) => FakeResponse.stream(chunks.stream, contentLength: 6),
);
final MemoryFileStore files = MemoryFileStore();
final DownloadTarget target = await files.create('견적서.pdf');
final List<(int, int?)> progress = <(int, int?)>[];
final Future<void> done = repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a 1',
target: target,
onProgress: (int received, int? total) =>
progress.add((received, total)),
);
await pumpEventsUntil(
() => h.server.requests.isNotEmpty,
reason: '요청이 서버에 닿기',
);
chunks.add(Uint8List.fromList(<int>[1, 2, 3]));
await pumpEventsUntil(() => progress.isNotEmpty, reason: '첫 조각');
chunks.add(Uint8List.fromList(<int>[4, 5, 6]));
await chunks.close();
await done;
await target.close();
expect(
MailApiPaths.attachment('m1', 'a 1'),
'/messages/m1/attachments/a%201',
);
expect(progress, <(int, int?)>[(3, 6), (6, 6)]);
expect(files.files[target.path], <int>[1, 2, 3, 4, 5, 6]);
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a 1'),
(_) => FakeResponse.stream(Stream<Uint8List>.value(Uint8List(2))),
);
final List<int?> totals = <int?>[];
await repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a 1',
target: await files.create('b'),
onProgress: (int _, int? total) => totals.add(total),
);
expect(totals, <int?>[null]);
});
test('첨부 받기의 실패 본문(스트림)도 코드로 읽는다 — 서버가 거절하면 인터셉터가 그 코드로 알린다', () async {
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a1'),
(_) => FakeResponse.fail(409, 'FILE_008'),
);
final MemoryFileStore files = MemoryFileStore();
final Object error = await errorOf(
repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a1',
target: await files.create('a'),
),
);
// 상태(409)로만 정하면 SYS_001 이다 — 본문의 코드를 읽었다
expect(apiErrorOf(error).code, 'FILE_008');
expect(apiErrorOf(error).traceId, 'trace-FILE_008');
expect(h.notifier.shownCodes, <String>['FILE_008']);
});
test(
'받는 도중 끊기거나 조각 사이 한도를 넘기면 SYS_001(DownloadInterrupted — 인터셉터는 알리지 않았다), 그만두면 취소',
() async {
final StreamController<Uint8List> broken =
StreamController<Uint8List>();
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a1'),
(_) => FakeResponse.stream(broken.stream, contentLength: 10),
);
final MemoryFileStore files = MemoryFileStore();
final Future<Object> failure = errorOf(
repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a1',
target: await files.create('a'),
),
);
await pumpEventsUntil(
() => h.server.requests.isNotEmpty,
reason: '요청이 서버에 닿기',
);
broken.add(Uint8List(3));
broken.addError(const SocketException('끊김(시험)'));
final Object error = await failure;
expect(error, isA<DownloadInterrupted>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(h.notifier.shownCodes, isEmpty);
// 조각 사이 받기 한도(Dio 가 스트림에 싣는 receiveTimeout)도 끊긴 것이다 — 그만둔 것(cancel)만 그대로 둔다
final StreamController<Uint8List> stalled =
StreamController<Uint8List>();
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a1'),
(_) => FakeResponse.stream(stalled.stream, contentLength: 10),
);
final Future<Object> timedOut = errorOf(
repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a1',
target: await files.create('t'),
),
);
await pumpEventsUntil(
() => h.server.requests.length == 2,
reason: '두 번째 요청이 서버에 닿기',
);
stalled.add(Uint8List(1));
stalled.addError(
DioException.receiveTimeout(
timeout: const Duration(seconds: 15),
requestOptions: RequestOptions(
path: MailApiPaths.attachment('m1', 'a1'),
),
),
);
final Object timeout = await timedOut;
expect(timeout, isA<DownloadInterrupted>());
expect(apiErrorOf(timeout).code, 'SYS_001');
expect(h.notifier.shownCodes, isEmpty);
final StreamController<Uint8List> slow = StreamController<Uint8List>();
h.server.on(
'GET',
MailApiPaths.attachment('m1', 'a1'),
(_) => FakeResponse.stream(slow.stream, contentLength: 10),
);
final CancelToken cancel = CancelToken();
final Future<Object> cancelled = errorOf(
repository.downloadAttachment(
messageId: 'm1',
attachmentId: 'a1',
target: await files.create('b'),
cancelToken: cancel,
),
);
await pumpEventsUntil(
() => h.server.requests.length == 3,
reason: '세 번째 요청이 서버에 닿기',
);
slow.add(Uint8List(2));
await Future<void>.delayed(Duration.zero);
cancel.cancel();
expect(
((await cancelled) as DioException).type,
DioExceptionType.cancel,
);
await slow.close();
},
);
test('바이트 응답의 실패 본문도 코드로 읽는다(그림 · 파일)', () async {
h.server.on('GET', '/files/x', (_) => FakeResponse.fail(410, 'FILE_007'));
final Object error = await errorOf(
h.dio.get<List<int>>(
'/files/x',
options: Options(responseType: ResponseType.bytes),
),
);
expect(apiErrorOf(error).code, 'FILE_007');
});
});
}
File diff suppressed because it is too large Load Diff