generated from ttipo/project-base
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:
@@ -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 <b> & c</p>'),
|
||||
'<p>a <b> & 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('글만 있다 <div>', 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&c=2">a</a>'),
|
||||
'<a href="HTTPS://Example.com/a?b=1&c=2">a</a>',
|
||||
);
|
||||
});
|
||||
|
||||
test('속성 값의 따옴표 · 꺾쇠는 늘 이스케이프한다', () {
|
||||
expect(
|
||||
clean('<span title="a"b>c<d" style="x">t</span>'),
|
||||
'<span title="a"b>c<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><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><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"><b>굵게</b>\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
Reference in New Issue
Block a user