feat(flutter): 메일 쓰기 화면 2차 — 첨부 고르기 · 올리기를 더한다

[파일 첨부]가 안드로이드 고르는 창(권한 없음)을 연다. 고른 파일은 앱 임시 폴더에
복사해 두고 그 복사본을 multipart 로 둘씩 올린다 — 올리기에 실패하거나 서버가 첨부를
찾지 못해도(MAIL_004) 다시 고르지 않고 다시 올리려고.

- 사전 검사 · 보내기 막기는 웹과 같은 차례, 한도는 GET /api/mail-quota
- 기기가 같은 차례로 미리 걸러 넣을 파일만 복사한다(content:// 만, 판정은 Dart 가 다시)
- 다 올리면 서버가 정리한 이름 · 크기로 싣는다(이름 255자 — 웹과 같다)
- MAIL_004 뒤 다시 올릴 수 없는 파일은 빼기 전까지 저장 · 보내기를 막는다
- 가져오는 · 올리는 동안 [보내기]를 막고(가져오기는 10분 시한), 올리는 중 나가기는 따로 묻는다
- 화면이 끝나거나 직원이 바뀌거나 앱을 띄울 때 복사본을 지운다

리뷰(M1~M4 · L 다섯 · 다시 본 리뷰의 L 하나)를 고쳤다. 시험 690 → 750.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 04:19:57 +09:00
parent f39f41ed4b
commit 3a126bdf83
27 changed files with 4360 additions and 136 deletions
@@ -0,0 +1,203 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/presentation/providers/attachment_uploads.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/file_fakes.dart';
/// 받은 올리기 하나 — 시험이 진행을 보내고 끝낸다
class _Call {
_Call(this.name, this.type, this.length, this.cancel, this.progress);
final String name;
final String type;
final int length;
final CancelToken cancel;
final void Function(int sent, int total) progress;
final Completer<MessageAttachment> done = Completer<MessageAttachment>();
void succeed(String id) => done.complete(
MessageAttachment(id: id, name: name, type: type, size: length),
);
void fail(String code) =>
done.completeError(apiFailure(RequestOptions(path: '/x'), code));
}
/// 첨부 올리기 차례(`AttachmentUploadQueue`) — 둘씩 · 고른 차례 · 진행 · 빼기 · 다시 올리기 · 그만두기
void main() {
late FakePickedFiles files;
late List<_Call> calls;
late List<String> events;
late AttachmentUploadQueue queue;
Future<MessageAttachment> upload({
required String name,
required String type,
required int length,
required Stream<List<int>> Function() read,
required CancelToken cancelToken,
required void Function(int sent, int total) onProgress,
}) {
final _Call call = _Call(name, type, length, cancelToken, onProgress);
calls.add(call);
unawaited(
cancelToken.whenCancel.then((DioException error) {
if (!call.done.isCompleted) call.done.completeError(error);
}),
);
return call.done.future;
}
setUp(() {
files = FakePickedFiles();
calls = <_Call>[];
events = <String>[];
queue = AttachmentUploadQueue(
upload: upload,
files: files,
onStarted: (int key) => events.add('start $key'),
onProgress: (int key, int percent) => events.add('$key $percent%'),
onDone: (int key, MessageAttachment attachment) =>
events.add('done $key ${attachment.id}'),
onFailed: (int key, ComposeUploadFailure failure) => events.add(
'failed $key ${failure.code ?? 'copyGone'} ${failure.canRetry}',
),
);
});
Future<void> settle() => Future<void>.delayed(Duration.zero);
/// 고른 파일 하나를 차례에 넣는다 — 복사본은 가짜 보관
PickedFile add(int key, String name, [int size = 4]) {
final PickedFile file = files.copy(name, List<int>.filled(size, key));
queue.add(key, path: file.path!, name: name, size: size);
return file;
}
test(
'고른 차례대로 둘까지만 올리고, 하나가 끝나면 다음 — 형식은 확장자의 정식 형식, 다 올린 소식에는 서버의 첨부',
() async {
add(1, 'a.pdf');
add(2, '보고서.hwp');
add(3, 'c.png');
await settle();
expect(events, <String>['start 1', 'start 2']);
expect(calls.map((_Call c) => c.name), <String>['a.pdf', '보고서.hwp']);
expect(calls[1].type, 'application/x-hwp');
calls[0].succeed('up-1');
await settle();
expect(events, <String>['start 1', 'start 2', 'done 1 up-1', 'start 3']);
expect(calls, hasLength(3));
expect(calls[2].type, 'image/png');
calls[1].succeed('up-2');
calls[2].succeed('up-3');
await settle();
expect(queue.busy, isFalse);
expect(events.last, 'done 3 up-3');
},
);
test('진행 — 보이는 비율이 바뀔 때만 알린다(전체를 모르면 알리지 않는다)', () async {
add(1, 'a.pdf', 200);
await settle();
final _Call call = calls.single;
call.progress(0, 200);
call.progress(1, 200);
call.progress(100, 200);
call.progress(101, 200);
call.progress(5, 0);
call.progress(200, 200);
expect(events, <String>['start 1', '1 50%', '1 100%']);
});
test('뺀 파일 — 올리던 것을 끊고 복사본을 지운다, 그 뒤 소식은 오지 않고 다음 차례가 시작한다', () async {
final PickedFile first = add(1, 'a.pdf');
add(2, 'b.pdf');
add(3, 'c.pdf');
await settle();
final CancelToken cancel = calls.first.cancel;
queue.remove(1);
await settle();
expect(cancel.isCancelled, isTrue);
expect(files.deleted, <String>[first.path!]);
expect(queue.hasCopy(1), isFalse);
expect(events, <String>['start 1', 'start 2', 'start 3']);
expect(calls.map((_Call c) => c.name), <String>['a.pdf', 'b.pdf', 'c.pdf']);
});
test('여럿을 뺄 때는 다 뺀 뒤에 다음 차례 — 뺄 파일이 그사이 시작하지 않는다', () async {
add(1, 'a.pdf');
add(2, 'b.pdf');
add(3, 'c.pdf');
add(4, 'd.pdf');
await settle();
queue.removeAll(<int>[1, 2, 3]);
await settle();
expect(events, <String>['start 1', 'start 2', 'start 4']);
expect(calls.map((_Call c) => c.name), <String>['a.pdf', 'b.pdf', 'd.pdf']);
});
test('실패 — 형식 · 크기 거절은 다시 할 수 없고, 연결 실패는 복사본으로 다시 올린다', () async {
add(1, 'a.pdf');
add(2, 'b.pdf');
await settle();
calls[0].fail('FILE_002');
calls[1].fail(kFallbackCode);
await settle();
expect(
events,
containsAllInOrder(<String>[
'failed 1 FILE_002 false',
'failed 2 SYS_001 true',
]),
);
expect(queue.busy, isFalse);
// [다시 시도] — 차례의 끝에 넣고 같은 복사본을 올린다
expect(queue.requeue(2), isTrue);
expect(queue.requeue(2), isFalse, reason: '이미 올리는 중');
await settle();
expect(calls.last.name, 'b.pdf');
calls.last.succeed('up-9');
await settle();
expect(events.last, 'done 2 up-9');
});
test('복사본이 없어졌으면 서버에 묻지 않고 "다시 골라야 함"', () async {
// 기기가 임시 폴더를 비웠다 — 차례가 오기 전에 복사본이 없어졌다
final PickedFile file = files.copy('a.pdf', <int>[1]);
files.copies.remove(file.path);
queue.add(1, path: file.path!, name: 'a.pdf', size: 1);
await settle();
expect(calls, isEmpty);
expect(events, <String>['start 1', 'failed 1 copyGone false']);
});
test('그만두기 — 올리던 것을 모두 끊고 복사본을 모두 지운다, 그 뒤 넣는 파일은 복사본만 지운다', () async {
add(1, 'a.pdf');
add(2, 'b.pdf');
add(3, 'c.pdf');
await settle();
queue.close();
await settle();
expect(calls.every((_Call c) => c.cancel.isCancelled), isTrue);
expect(files.copies, isEmpty);
expect(files.deleted, hasLength(3));
expect(events, <String>['start 1', 'start 2']);
add(4, 'd.pdf');
await settle();
expect(files.copies, isEmpty);
expect(events, hasLength(2));
expect(queue.requeue(1), isFalse);
});
}
@@ -0,0 +1,361 @@
import 'dart:io';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/core/utils/file_size.dart';
import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/domain/compose_attachments.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart';
import 'package:flutter_test/flutter_test.dart';
const int _mb = 1024 * 1024;
/// 첨부의 규칙 — 형식 표(백엔드 짝) · 고른 파일 사전 검사 차례 · 한도 읽기 · 넣지 못한 알림 글 · 줄의 셈
void main() {
group('형식 표 (짝: 백엔드 attachment.policy.ts)', () {
test('확장자 → 정식 형식이 백엔드 ATTACHMENT_TYPES 의 type 과 같다 · 개수 한도도 같다', () {
// flutter/ 에서 돈다(flutter test) — 모노레포의 백엔드 파일을 읽어 맞춰 본다
final File policy = File(
'../backend/src/modules/mail/attachment.policy.ts',
);
expect(
policy.existsSync(),
isTrue,
reason: '백엔드 첨부 정책 파일이 없다 — 짝을 맞춰 볼 수 없다',
);
final String source = policy.readAsStringSync();
final int start = source.indexOf('const ATTACHMENT_TYPES');
final int end = source.indexOf(']);', start);
expect(start, isNot(-1));
final Map<String, String> backend = <String, String>{
for (final RegExpMatch match in RegExp(
r"\[\s*'\.([a-z0-9]+)',\s*\{\s*type:\s*'([^']+)'",
).allMatches(source.substring(start, end)))
match.group(1)!: match.group(2)!,
};
expect(backend, hasLength(kMailAttachmentTypes.length));
expect(backend, kMailAttachmentTypes);
expect(
RegExp(
r'MAX_ATTACHMENTS_PER_MESSAGE = (\d+);',
).firstMatch(source)?.group(1),
'$kMailMaxAttachments',
);
});
test('올릴 때의 형식 — 확장자는 대소문자를 가리지 않는다 · HEIC · 확장자 없음은 허용하지 않는다', () {
expect(attachmentUploadTypeOf('계약.PDF'), 'application/pdf');
expect(attachmentUploadTypeOf('보고서.hwp'), 'application/x-hwp');
expect(attachmentUploadTypeOf('보고서.HwPx'), 'application/hwp+zip');
expect(attachmentUploadTypeOf('사진.jpeg'), 'image/jpeg');
expect(attachmentUploadTypeOf('묶음.tar.zip'), 'application/zip');
expect(attachmentUploadTypeOf('IMG_0001.HEIC'), isNull);
expect(attachmentUploadTypeOf('설치.exe'), isNull);
expect(attachmentUploadTypeOf('이름만'), isNull);
expect(isImageAttachment('a.WEBP'), isTrue);
expect(isImageAttachment('a.pdf'), isFalse);
expect(isImageAttachment('a.heic'), isFalse);
});
});
group('고른 파일 사전 검사 (웹 precheckAttachment 와 같은 차례)', () {
const AttachmentLimits limits = AttachmentLimits(
maxBytes: 20 * _mb,
totalMaxBytes: 25 * _mb,
);
PickedFile ok(String name, int size) => PickedFile(
name: name,
status: PickedFileStatus.ok,
size: size,
path: '/c/uploads/x/$name',
);
AttachmentRejection? check(
PickedFile file, {
int count = 0,
int bytes = 0,
}) => precheckPickedFile(
file,
attachedCount: count,
attachedBytes: bytes,
limits: limits,
);
test('통과', () {
expect(check(ok('견적서.pdf', 5 * _mb), count: 19, bytes: 20 * _mb), isNull);
});
test('개수가 먼저 — 허용 밖 형식이어도 20개를 넘으면 개수, 가져오지 않은 파일도 개수', () {
expect(check(ok('a.pdf', 1), count: 20), AttachmentRejection.tooMany);
expect(check(ok('a.heic', 1), count: 20), AttachmentRejection.tooMany);
expect(
check(
const PickedFile(name: 'a.heic', status: PickedFileStatus.skipped),
),
AttachmentRejection.tooMany,
);
});
test('형식 다음 크기 — 큰 HEIC 는 형식, 큰 PDF 는 크기(복사하다 멈춘 것 · 알려 준 크기 모두)', () {
expect(
check(
const PickedFile(
name: 'IMG_0001.HEIC',
status: PickedFileStatus.tooLarge,
),
),
AttachmentRejection.notAllowed,
);
expect(
check(
const PickedFile(name: 'a.pdf', status: PickedFileStatus.tooLarge),
),
AttachmentRejection.tooLarge,
);
expect(check(ok('a.pdf', 20 * _mb + 1)), AttachmentRejection.tooLarge);
expect(check(ok('a.pdf', 20 * _mb)), isNull);
});
test('읽지 못한 파일은 형식 · 크기 다음, 합계는 마지막', () {
expect(
check(const PickedFile(name: 'a.exe', status: PickedFileStatus.failed)),
AttachmentRejection.notAllowed,
);
expect(
check(const PickedFile(name: 'a.pdf', status: PickedFileStatus.failed)),
AttachmentRejection.unreadable,
);
expect(
check(ok('a.pdf', 6 * _mb), bytes: 20 * _mb),
AttachmentRejection.overTotal,
);
expect(check(ok('a.pdf', 5 * _mb), bytes: 20 * _mb), isNull);
});
test('기기가 같은 차례로 미리 거른 까닭(형식 · 합계)은 그 까닭으로 옮긴다 — 차례가 앞선 까닭이 이긴다', () {
// 기기가 형식으로 걸렀다 — 이름이 허용 형식처럼 보여도 기기의 판정을 따른다(넣지 않는다)
expect(
check(
const PickedFile(
name: 'a.pdf',
status: PickedFileStatus.notAllowed,
size: 1,
),
),
AttachmentRejection.notAllowed,
);
expect(
check(
const PickedFile(
name: '도면.pdf',
status: PickedFileStatus.overTotal,
size: 6 * _mb,
),
),
AttachmentRejection.overTotal,
);
// 개수가 먼저 · 파일당이 합계보다 먼저(웹과 같은 차례)
expect(
check(
const PickedFile(
name: '도면.pdf',
status: PickedFileStatus.overTotal,
size: 6 * _mb,
),
count: 20,
),
AttachmentRejection.tooMany,
);
expect(
check(
const PickedFile(
name: '도면.pdf',
status: PickedFileStatus.overTotal,
size: 21 * _mb,
),
),
AttachmentRejection.tooLarge,
);
});
test('이미 붙은 첨부(전달 · 저장본)는 서버 합계 한도로 잰다', () {
expect(
precheckCarriedAttachment(
name: 'a.zip',
size: 6 * _mb,
attachedCount: 0,
attachedBytes: 5 * _mb,
totalMaxBytes: 10 * _mb,
),
'FILE_002',
);
expect(
precheckCarriedAttachment(
name: 'a.zip',
size: 5 * _mb,
attachedCount: 0,
attachedBytes: 5 * _mb,
totalMaxBytes: 10 * _mb,
),
isNull,
);
});
});
group('첨부 한도 (GET /api/mail-quota)', () {
test('파일당 · 합계만 읽는다 — 없거나 0 이하면 null(대체값을 쓴다)', () {
expect(
AttachmentLimits.tryParse(<String, Object?>{
'usedBytes': 1,
'limitBytes': null,
'attachmentMaxBytes': 30 * _mb,
'attachmentTotalMaxBytes': 40.0 * _mb,
}),
const AttachmentLimits(maxBytes: 30 * _mb, totalMaxBytes: 40 * _mb),
);
expect(
AttachmentLimits.tryParse(<String, Object?>{'attachmentMaxBytes': 1}),
isNull,
);
expect(
AttachmentLimits.tryParse(<String, Object?>{
'attachmentMaxBytes': 0,
'attachmentTotalMaxBytes': 1,
}),
isNull,
);
expect(AttachmentLimits.tryParse('x'), isNull);
expect(
kFallbackAttachmentLimits,
const AttachmentLimits(maxBytes: 20 * _mb, totalMaxBytes: 25 * _mb),
);
});
});
group('글', () {
test('한도 글 — 웹 formatMegabytes 와 같다(정수면 그대로, 아니면 소수 한 자리)', () {
expect(formatMegabytes(20 * _mb), '20MB');
expect(formatMegabytes(25 * _mb), '25MB');
expect(formatMegabytes(20 * _mb + _mb ~/ 2), '20.5MB');
expect(formatMegabytes(_mb ~/ 2), '0.5MB');
expect(formatMegabytes(20 * _mb - 1), '20MB');
expect(
MailComposeTexts.attachmentLimits(
formatMegabytes(20 * _mb),
formatMegabytes(25 * _mb),
),
'파일당 최대 20MB · 합계 25MB',
);
});
test('은/는 — 받침에 맞추고, 한글로 끝나지 않으면 "은(는)"', () {
expect(topicParticle('사진'), '은');
expect(topicParticle('보고서'), '는');
expect(topicParticle('외 2개'), '는');
expect(topicParticle('견적서.pdf'), '은(는)');
expect(topicParticle(''), '은(는)');
});
test('넣지 못한 알림 — 까닭마다 한 문장, 처음 나온 차례대로 줄을 바꿔 잇는다', () {
const AttachmentLimits limits = AttachmentLimits(
maxBytes: 20 * _mb,
totalMaxBytes: 25 * _mb,
);
expect(
pickRejectionNotice(<AttachmentRejection, List<String>>{
AttachmentRejection.overTotal: <String>['현장사진.jpg'],
}, limits),
'첨부를 모두 합치면 25MB를 넘어 ‘현장사진.jpg’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.',
);
expect(
pickRejectionNotice(<AttachmentRejection, List<String>>{
AttachmentRejection.overTotal: <String>['a.jpg', 'b.jpg', 'c.jpg'],
}, limits),
'첨부를 모두 합치면 25MB를 넘어 ‘a.jpg’ 외 2개는 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.',
);
expect(
pickRejectionNotice(<AttachmentRejection, List<String>>{
AttachmentRejection.notAllowed: <String>['IMG_0001.HEIC', 'b.exe'],
AttachmentRejection.tooLarge: <String>['영상.mp4'],
AttachmentRejection.tooMany: <String>['z.pdf'],
AttachmentRejection.unreadable: <String>['드라이브.pdf'],
}, const AttachmentLimits(maxBytes: 30 * _mb, totalMaxBytes: 40 * _mb)),
<String>[
ErrorLexicon.getMessage('FILE_001'),
'30MB보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.',
ErrorLexicon.getMessage('MAIL_006'),
'파일을 가져오지 못했습니다. 다시 골라 주세요.',
].join('\n'),
);
});
});
group('첨부 줄', () {
const ComposeAttachment carried = ComposeAttachment(
key: 1,
id: 'a1',
name: 'a.pdf',
size: 3,
);
test('보낼 수 있는가 · 올리는 중인가 · 한도에 세는가', () {
final ComposeAttachment picked = carried.withUpload(
ComposeUploadStatus.waiting,
);
expect(carried.sendable, isTrue);
expect(picked.id, '');
expect(picked.sendable, isFalse);
expect(picked.uploading, isTrue);
expect(picked.countsTowardLimits, isTrue);
final ComposeAttachment done = picked.withUpload(
ComposeUploadStatus.done,
id: 'up-1',
progress: 100,
);
expect(done.sendable, isTrue);
expect(done.uploading, isFalse);
final ComposeAttachment retryable = picked.withUpload(
ComposeUploadStatus.failed,
failure: ComposeUploadFailure.of('SYS_001'),
);
expect(retryable.failure?.canRetry, isTrue);
expect(retryable.countsTowardLimits, isTrue);
expect(retryable.sendable, isFalse);
for (final String code in <String>['FILE_001', 'FILE_002', 'VAL_001']) {
expect(ComposeUploadFailure.of(code).canRetry, isFalse, reason: code);
}
expect(
picked
.withUpload(
ComposeUploadStatus.failed,
failure: ComposeUploadFailure.copyGone,
)
.countsTowardLimits,
isFalse,
);
expect(
carried
.copyWith(problem: ComposeAttachmentProblem.missing)
.countsTowardLimits,
isFalse,
);
// 다시 올릴 수 없어 "찾지 못함"이 된 고른 파일 — 보내지도 세지도 않는다(빼기 전까지 막는다)
final ComposeAttachment missing = done.copyWith(
problem: ComposeAttachmentProblem.missing,
);
expect(missing.sendable, isFalse);
expect(missing.countsTowardLimits, isFalse);
// 다 올리면 서버가 정리한 이름 · 크기로
final ComposeAttachment renamed = picked.withUpload(
ComposeUploadStatus.done,
id: 'up-2',
name: '줄인 이름.pdf',
size: 9,
);
expect((renamed.name, renamed.size), ('줄인 이름.pdf', 9));
});
});
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:arasystem_app/core/api/api_paths.dart';
@@ -5,6 +7,7 @@ import 'package:arasystem_app/core/people/person_address.dart';
import 'package:arasystem_app/core/people/recipient_suggestion.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:dio/dio.dart';
@@ -387,4 +390,202 @@ void main() {
);
expect(h.notifier.shownCodes, isEmpty);
});
group('첨부 올리기 · 첨부 한도 (계약 2절)', () {
Map<String, Object?> created(
String id,
String name,
String type,
int size,
) => <String, Object?>{
'success': true,
'data': attachmentJson(id, name: name, type: type, size: size),
};
test(
'POST /mail-attachments — multipart file 하나(보이는 이름 · 확장자의 정식 형식 · 길이), 보내기 5분 · 받기 60초 · 알리지 않는다 · 보낸 진행',
() async {
h.server.readRequestBodies = true;
h.server.on(
'POST',
ComposeApiPaths.mailAttachments,
(_) => FakeResponse(
201,
created('att-1', '견적서 (최종).hwp', 'application/x-hwp', 5),
),
);
final List<(int, int)> progress = <(int, int)>[];
final MessageAttachment uploaded = await repository.uploadAttachment(
name: '견적서 (최종).hwp',
type: 'application/x-hwp',
length: 5,
read: () => Stream<List<int>>.fromIterable(<List<int>>[
<int>[0x41, 0x42],
<int>[0x43, 0x44, 0x45],
]),
cancelToken: CancelToken(),
onProgress: (int sent, int total) => progress.add((sent, total)),
);
expect(uploaded.id, 'att-1');
expect(uploaded.size, 5);
final RequestOptions post = h.server.requests.single;
expect(post.sendTimeout, kAttachmentUploadSendTimeout);
expect(post.receiveTimeout, kAttachmentUploadReceiveTimeout);
expect(bearerTokenOf(post), 'access-1');
final MapEntry<String, MultipartFile> file =
(post.data! as FormData).files.single;
expect(file.key, kAttachmentFileField);
expect(file.value.filename, '견적서 (최종).hwp');
expect(file.value.contentType.toString(), 'application/x-hwp');
expect(file.value.length, 5);
// 본문 — 한글 이름은 UTF-8 그대로(백엔드 multer `defParamCharset: utf8`), 내용은 다시 읽은 바이트
final String body = utf8.decode(h.server.requestBodies.single.body);
expect(
body,
contains(
'content-disposition: form-data; name="file"; filename="견적서 (최종).hwp"\r\n'
'content-type: application/x-hwp\r\n\r\nABCDE\r\n',
),
);
expect(progress, isNotEmpty);
expect(progress.last.$1, progress.last.$2);
expect(h.notifier.shownCodes, isEmpty);
},
);
test(
'실패는 알리지 않는다(화면이 그 줄에 보인다) · 취소하면 cancel · 모양이 틀린 응답은 SYS_001',
() async {
h.server.on(
'POST',
ComposeApiPaths.mailAttachments,
(_) => FakeResponse.fail(400, 'FILE_001'),
);
Future<MessageAttachment> upload(CancelToken cancel) =>
repository.uploadAttachment(
name: 'a.pdf',
type: 'application/pdf',
length: 1,
read: () => Stream<List<int>>.value(<int>[1]),
cancelToken: cancel,
onProgress: (int sent, int total) {},
);
expect(
apiErrorOf(await errorOf(upload(CancelToken()))).code,
'FILE_001',
);
expect(h.notifier.shownCodes, isEmpty);
final Completer<void> gate = Completer<void>();
h.server.on('POST', ComposeApiPaths.mailAttachments, (_) async {
await gate.future;
return FakeResponse(
201,
created('att-2', 'a.pdf', 'application/pdf', 1),
);
});
final CancelToken cancel = CancelToken();
final Future<Object> canceled = errorOf(upload(cancel));
cancel.cancel();
final Object error = await canceled;
expect((error as DioException).type, DioExceptionType.cancel);
gate.complete();
h.server.on(
'POST',
ComposeApiPaths.mailAttachments,
(_) => const FakeResponse(201, <String, Object?>{
'success': true,
'data': <String, Object?>{'id': 'x'},
}),
);
expect(
apiErrorOf(await errorOf(upload(CancelToken()))).code,
'SYS_001',
);
},
);
test('토큰이 끝나 401 이면 갱신한 뒤 같은 파일을 처음부터 다시 보낸다(FormData 복제)', () async {
int next = 2;
h.server.readRequestBodies = true;
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': tokensJson(next++, now: h.clock.now),
}),
);
h.server.on(
'POST',
ComposeApiPaths.mailAttachments,
(RequestOptions options) => bearerTokenOf(options) == 'access-2'
? FakeResponse(201, created('att-3', 'a.png', 'image/png', 3))
: FakeResponse.fail(401, 'AUTH_001'),
);
int reads = 0;
final MessageAttachment uploaded = await repository.uploadAttachment(
name: 'a.png',
type: 'image/png',
length: 3,
read: () {
reads++;
return Stream<List<int>>.value(<int>[7, 8, 9]);
},
cancelToken: CancelToken(),
onProgress: (int sent, int total) {},
);
expect(uploaded.id, 'att-3');
final List<({String path, List<int> body})> bodies = h
.server
.requestBodies
.where(
(({String path, List<int> body}) b) =>
b.path == ComposeApiPaths.mailAttachments,
)
.toList();
expect(bodies, hasLength(2));
expect(bodies[1].body, bodies[0].body);
expect(reads, 2);
});
test(
'첨부 한도 — GET /mail-quota 의 파일당 · 합계, 알리지 않는다 · 모양이 틀리면 SYS_001',
() async {
h.server.on(
'GET',
ComposeApiPaths.mailQuota,
(_) => FakeResponse.ok(<String, Object?>{
'usedBytes': 10,
'limitBytes': null,
'attachmentMaxBytes': 30,
'attachmentTotalMaxBytes': 40,
}),
);
expect(
await repository.getAttachmentLimits(),
const AttachmentLimits(maxBytes: 30, totalMaxBytes: 40),
);
h.server.on(
'GET',
ComposeApiPaths.mailQuota,
(_) => FakeResponse.ok(<String, Object?>{'usedBytes': 10}),
);
expect(
apiErrorOf(await errorOf(repository.getAttachmentLimits())).code,
'SYS_001',
);
h.server.on(
'GET',
ComposeApiPaths.mailQuota,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
await errorOf(repository.getAttachmentLimits());
expect(h.notifier.shownCodes, isEmpty);
},
);
});
}
@@ -4,6 +4,7 @@ import 'package:arasystem_app/core/people/person_address.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/domain/compose_attachments.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_fit.dart';
@@ -1,10 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/core/people/people_texts.dart';
import 'package:arasystem_app/core/theme/app_colors.dart';
import 'package:arasystem_app/core/utils/file_size.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/mail/domain/compose_attachments.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_fit.dart';
@@ -17,9 +22,11 @@ import 'package:arasystem_app/features/mail/presentation/pages/message_reader_pa
import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_sections.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -110,9 +117,28 @@ void main() {
await tester.pump();
}
/// 마지막 고르기에 넘긴 한도 — 남은 개수 · 파일당 · 남은 합계, 허용 확장자는 늘 형식 표 그대로
void expectPick({
required int maxBytes,
required int maxCount,
required int maxTotalBytes,
}) {
final ({
int maxBytes,
int maxCount,
int maxTotalBytes,
List<String> extensions,
})
pick = h.picked.picks.last;
expect(pick.maxBytes, maxBytes);
expect(pick.maxCount, maxCount);
expect(pick.maxTotalBytes, maxTotalBytes);
expect(pick.extensions, unorderedEquals(kMailAttachmentTypes.keys));
}
group('열기', () {
testWidgets(
'새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹을 미리 받는다 · [파일 첨부]는 준비 중',
'새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹 · 첨부 한도를 미리 받는다 · [파일 첨부]는 고르는 창(닫으면 그대로)',
(WidgetTester tester) async {
await start(tester);
await open(tester, MailRoutes.compose);
@@ -130,8 +156,18 @@ void main() {
findsNothing,
);
expect(h.server.received('GET', '/mail-quota'), hasLength(1));
// 고르는 창 — 남은 자리 · 파일당 · 남은 합계 · 허용 확장자를 넘긴다. 고르지 않고 닫으면 아무것도 없다
await tapTooltip(tester, MailComposeTexts.attach);
expect(snack(MailComposeTexts.attachNotReady), findsOneWidget);
expect(h.picked.picks, hasLength(1));
expectPick(
maxBytes: 20 * 1024 * 1024,
maxCount: 20,
maxTotalBytes: 25 * 1024 * 1024,
);
expect(find.byType(SnackBar), findsNothing);
expect(find.text(MailComposeTexts.attachmentsTitle(0)), findsNothing);
await tapTooltip(tester, MailComposeTexts.expandCopies);
expect(field(RecipientFieldKind.cc), findsOneWidget);
@@ -1110,4 +1146,847 @@ void main() {
expect(h.server.received('GET', '/drafts/saved-1'), hasLength(1));
});
});
group('첨부 고르기 · 올리기 (2차)', () {
const int mb = 1024 * 1024;
/// 고른 파일 — 크기만 정하고 복사본은 작게 둔다(가짜 서버는 본문을 읽지 않는다)
PickedFile picked(String name, {int size = 4}) {
final PickedFile copy = h.picked.copy(name, <int>[1, 2, 3, 4]);
return PickedFile(
name: name,
status: PickedFileStatus.ok,
size: size,
path: copy.path,
);
}
Future<void> attach(WidgetTester tester, List<PickedFile> files) async {
h.picked.willPick(files);
await tapTooltip(tester, MailComposeTexts.attach);
}
/// 다음 올리기 하나의 응답을 붙잡는다
Completer<void> holdUpload() {
final Completer<void> gate = Completer<void>();
c.uploadGates.add(gate);
return gate;
}
List<String> uploadedNames() => <String>[
for (final ({String name, String type, int length}) upload in c.uploads)
upload.name,
];
Finder inAttachments(Finder finder) => find.descendant(
of: find.byType(ComposeAttachmentsSection),
matching: finder,
);
testWidgets(
'[파일 첨부] — 고른 파일을 둘씩 올린다: "올리는 중 N%" → 크기, 첨부 머리에 서버 한도 · 보이는 이름 · 확장자의 정식 형식',
(WidgetTester tester) async {
c.quota = quotaJson(maxBytes: 30 * mb, totalMaxBytes: 40 * mb);
await start(tester);
await open(tester, MailRoutes.compose);
final Completer<void> first = holdUpload();
final Completer<void> second = holdUpload();
final Completer<void> third = holdUpload();
await attach(tester, <PickedFile>[
picked('견적서.pdf'),
picked('현장사진.jpg'),
picked('보고서.hwp'),
]);
expect(h.picked.picks, hasLength(1));
expectPick(maxBytes: 30 * mb, maxCount: 20, maxTotalBytes: 40 * mb);
expect(find.text(MailComposeTexts.attachmentsTitle(3)), findsOneWidget);
expect(find.text('파일당 최대 30MB · 합계 40MB'), findsOneWidget);
expect(find.text(MailComposeTexts.uploadProgress(0)), findsNWidgets(3));
expect(
find.byTooltip(MailComposeTexts.cancelUpload('견적서.pdf')),
findsOneWidget,
);
expect(
find.bySemanticsLabel(MailComposeTexts.uploading('현장사진.jpg')),
findsOneWidget,
);
// 둘까지만 서버에 보낸다 — 셋째는 기다린다
expect(uploadedNames(), <String>['견적서.pdf', '현장사진.jpg']);
expect(
c.uploads.map((({String name, String type, int length}) u) => u.type),
<String>['application/pdf', 'image/jpeg'],
);
first.complete();
await tester.pumpAndSettle();
expect(uploadedNames().last, '보고서.hwp');
expect(c.uploads.last.type, 'application/x-hwp');
expect(
find.byTooltip(MailComposeTexts.remove('견적서.pdf')),
findsOneWidget,
);
expect(find.text(formatFileSize(4)), findsOneWidget);
second.complete();
third.complete();
await tester.pumpAndSettle();
expect(find.text(MailComposeTexts.uploadProgress(0)), findsNothing);
expect(find.text(formatFileSize(4)), findsNWidgets(3));
expect(h.notifier.shownCodes, isEmpty);
},
);
testWidgets(
'올리는 동안 [보내기]는 까닭을 알리고 보내지 않는다 — 임시 저장은 다 올린 첨부만 싣고, 다 올리면 보낸다(닫히면 복사본을 지운다)',
(WidgetTester tester) async {
await start(tester);
await open(tester, MailRoutes.compose);
await addRecipient(tester, 'doyun@x.kr');
final Completer<void> gate = holdUpload();
await attach(tester, <PickedFile>[picked('견적서.pdf')]);
await tapTooltip(tester, MailComposeTexts.send);
expect(snack(MailComposeTexts.attachmentsUploading), findsOneWidget);
expect(c.sends, isEmpty);
// 쓰는 사이의 저장 — 올리는 중인 첨부는 싣지 않는다
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(c.saves.single.body['attachments'], isEmpty);
gate.complete();
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 10));
await tester.pumpAndSettle();
expect(c.saves, hasLength(2));
expect(c.saves.last.body['attachments'], <Object?>[
<String, Object?>{'id': 'up-1', 'name': '견적서.pdf'},
]);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends.single.body['attachments'], <Object?>[
<String, Object?>{'id': 'saved-2-up-1', 'name': '견적서.pdf'},
]);
expect(find.byType(MessageComposePage), findsNothing);
expect(h.picked.copies, isEmpty);
},
);
testWidgets(
'사전 검사 — 형식(HEIC) · 크기 · 읽지 못함 · 개수는 넣지 않고 알림 하나로 까닭을 모은다(그 복사본은 지운다)',
(WidgetTester tester) async {
await start(tester);
await open(tester, MailRoutes.compose);
final PickedFile heic = picked('IMG_0001.HEIC');
await attach(tester, <PickedFile>[
heic,
const PickedFile(
name: '큰자료.pdf',
status: PickedFileStatus.tooLarge,
size: 900 * mb,
),
picked('견적서.pdf'),
const PickedFile(name: '드라이브.pdf', status: PickedFileStatus.failed),
const PickedFile(name: 'z.pdf', status: PickedFileStatus.skipped),
]);
expect(find.text(MailComposeTexts.attachmentsTitle(1)), findsOneWidget);
expect(find.text('IMG_0001.HEIC'), findsNothing);
expect(find.text('큰자료.pdf'), findsNothing);
expect(
snack(
<String>[
ErrorLexicon.getMessage('FILE_001'),
'20MB보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.',
'파일을 가져오지 못했습니다. 다시 골라 주세요.',
ErrorLexicon.getMessage('MAIL_006'),
].join('\n'),
),
findsOneWidget,
);
expect(h.picked.deleted, <String>[heic.path!]);
expect(uploadedNames(), <String>['견적서.pdf']);
},
);
testWidgets('합계 한도 — 넘게 만드는 파일만 넣지 않는다(이름 + 은(는)) · 전달하는 원본의 합계도 서버 한도로 잰다', (
WidgetTester tester,
) async {
c.quota = quotaJson(totalMaxBytes: mb);
h.mail.put(
kInboxId,
messageJson('f3', mailboxId: kInboxId, subject: '자료', isRead: true),
detail: <String, Object?>{
'attachments': <Object?>[
attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024),
attachmentJson('a2', name: '도면.pdf', size: 600 * 1024),
],
},
);
await start(tester);
await open(tester, MailRoutes.composeForward('f3'));
// 둘째 원본은 합계(1MB)를 넘겨 싣지 않는다
expect(findCopy(MailComposeTexts.unsendable), findsOneWidget);
await attach(tester, <PickedFile>[
picked('a.pdf', size: 500 * 1024),
picked('b.pdf', size: 300 * 1024),
]);
expect(find.text('a.pdf'), findsNothing);
expect(find.text('b.pdf'), findsOneWidget);
expect(
snack(
'첨부를 모두 합치면 1MB를 넘어 ‘a.pdf’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.',
),
findsOneWidget,
);
});
testWidgets('첨부 한도가 늦으면 2초까지만 기다리고 대체값으로 연다 — 받으면 한도 글이 바뀐다', (
WidgetTester tester,
) async {
c.quota = quotaJson(maxBytes: 5 * mb, totalMaxBytes: mb);
final Completer<void> gate = Completer<void>();
c.quotaGate = gate;
h.mail.put(
kInboxId,
messageJson('f5', mailboxId: kInboxId, subject: '자료', isRead: true),
detail: <String, Object?>{
'attachments': <Object?>[
attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024),
attachmentJson('a2', name: '도면.pdf', size: 600 * 1024),
],
},
);
await start(tester);
unawaited(h.router.push<void>(MailRoutes.composeForward('f5')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsNothing);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
// 대체값(합계 25MB)으로 쟀다 — 둘 다 싣는다
expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget);
expect(findCopy(MailComposeTexts.unsendable), findsNothing);
expect(find.text('파일당 최대 20MB · 합계 25MB'), findsOneWidget);
gate.complete();
await tester.pumpAndSettle();
expect(find.text('파일당 최대 5MB · 합계 1MB'), findsOneWidget);
});
testWidgets('이미 20개면 고르는 창을 열지 않고 알린다 · 남은 자리만큼만 가져오게 한다', (
WidgetTester tester,
) async {
c.putDraft(
'd1',
attachments: <Map<String, Object?>>[
for (int i = 0; i < 18; i++)
attachmentJson('a$i', name: '자료$i.pdf', size: 10),
],
);
await start(tester);
await open(tester, MailRoutes.composeDraft('d1'));
await attach(tester, <PickedFile>[picked('a.pdf'), picked('b.pdf')]);
// 남은 자리 2 · 남은 합계 25MB − 18 × 10바이트
expectPick(maxBytes: 20 * mb, maxCount: 2, maxTotalBytes: 25 * mb - 180);
expect(find.text(MailComposeTexts.attachmentsTitle(20)), findsOneWidget);
await tapTooltip(tester, MailComposeTexts.attach);
expect(h.picked.picks, hasLength(1));
expect(snack(ErrorLexicon.getMessage('MAIL_006')), findsOneWidget);
});
testWidgets(
'✕ 는 올리기 취소 — 줄을 빼고 복사본을 지우고 기다리던 파일이 이어 올라간다 · 한도를 받지 못하면 20 · 25MB',
(WidgetTester tester) async {
c.quota = null;
await start(tester);
await open(tester, MailRoutes.compose);
holdUpload();
holdUpload();
final Completer<void> third = holdUpload();
final PickedFile first = picked('a.pdf');
await attach(tester, <PickedFile>[
first,
picked('b.pdf'),
picked('c.pdf'),
]);
expect(h.picked.picks, hasLength(1));
expectPick(maxBytes: 20 * mb, maxCount: 20, maxTotalBytes: 25 * mb);
expect(find.text('파일당 최대 20MB · 합계 25MB'), findsOneWidget);
expect(uploadedNames(), <String>['a.pdf', 'b.pdf']);
await tapTooltip(tester, MailComposeTexts.cancelUpload('a.pdf'));
expect(find.text('a.pdf'), findsNothing);
expect(h.picked.deleted, <String>[first.path!]);
expect(uploadedNames(), <String>['a.pdf', 'b.pdf', 'c.pdf']);
expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget);
third.complete();
await tester.pumpAndSettle();
expect(
find.byTooltip(MailComposeTexts.remove('c.pdf')),
findsOneWidget,
);
expect(
find.byTooltip(MailComposeTexts.cancelUpload('b.pdf')),
findsOneWidget,
);
},
);
testWidgets(
'올리지 못하면 "올리지 못했습니다." · 사전 문구 · [다시 시도](복사본으로 다시) — 형식 거절은 빼기만, 알림은 띄우지 않는다',
(WidgetTester tester) async {
await start(tester);
await open(tester, MailRoutes.compose);
c.uploadFailures.addAll(<FakeResponse>[
FakeResponse.fail(503, 'MAIL_001'),
FakeResponse.fail(400, 'FILE_001'),
]);
await attach(tester, <PickedFile>[picked('a.pdf'), picked('b.pdf')]);
expect(find.text(MailComposeTexts.uploadFailed), findsNWidgets(2));
expect(
inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_001'))),
findsOneWidget,
);
expect(
inAttachments(findCopy(ErrorLexicon.getMessage('FILE_001'))),
findsOneWidget,
);
expect(find.text(MailComposeTexts.retryUpload), findsOneWidget);
expect(
findCopy(MailComposeTexts.retryUploadLabel('a.pdf')),
findsOneWidget,
);
expect(h.notifier.shownCodes, isEmpty);
await tester.tap(find.text(MailComposeTexts.retryUpload));
await tester.pumpAndSettle();
expect(uploadedNames(), <String>['a.pdf', 'b.pdf', 'a.pdf']);
expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget);
expect(
find.byTooltip(MailComposeTexts.remove('a.pdf')),
findsOneWidget,
);
expect(find.text(MailComposeTexts.retryUpload), findsNothing);
},
);
testWidgets(
'보내기가 첨부를 찾지 못하면(MAIL_004) 고른 파일은 복사본으로 다시 올리고, 전달 원본은 "찾지 못함" — 빼면 보낸다',
(WidgetTester tester) async {
h.mail.put(
kInboxId,
messageJson('f4', mailboxId: kInboxId, subject: '자료', isRead: true),
detail: <String, Object?>{
'attachments': <Object?>[attachmentJson('a1', name: '원본.pdf')],
},
);
await start(tester);
await open(tester, MailRoutes.composeForward('f4'));
await attach(tester, <PickedFile>[picked('추가.pdf')]);
await addRecipient(tester, 'doyun@x.kr');
c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004'));
await tapTooltip(tester, MailComposeTexts.send);
expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget);
expect(
inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_004'))),
findsOneWidget,
);
expect(uploadedNames(), <String>['추가.pdf', '추가.pdf']);
expect(
find.byTooltip(MailComposeTexts.remove('추가.pdf')),
findsOneWidget,
);
await tapTooltip(tester, MailComposeTexts.remove('원본.pdf'));
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends.last.body['attachments'], <Object?>[
<String, Object?>{'id': 'up-2', 'name': '추가.pdf'},
]);
},
);
testWidgets(
'올리는 중에 나가면 따로 묻는다 — [계속 쓰기]면 그대로, [나가기]면 올리던 파일을 빼고(복사본을 지운다) 닫는다',
(WidgetTester tester) async {
await start(tester);
await open(tester, MailRoutes.compose);
holdUpload();
final PickedFile file = picked('a.pdf');
await attach(tester, <PickedFile>[file]);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget);
expect(findCopy(MailComposeTexts.uploadLeaveMessage), findsOneWidget);
expect(
find.widgetWithText(FilledButton, MailComposeTexts.stay),
findsOneWidget,
);
await tester.tap(find.text(MailComposeTexts.stay));
await tester.pumpAndSettle();
expect(find.byType(MessageComposePage), findsOneWidget);
expect(find.text(MailComposeTexts.uploadProgress(0)), findsOneWidget);
await tapTooltip(tester, MailComposeTexts.close);
await tester.tap(find.text(MailComposeTexts.leave));
await tester.pumpAndSettle();
expect(find.byType(MessageComposePage), findsNothing);
expect(h.picked.deleted, contains(file.path));
expect(c.saves, isEmpty);
expect(snack(MailComposeTexts.savedOnLeave), findsNothing);
expect(find.text(MailComposeTexts.leaveTitle), findsNothing);
},
);
testWidgets('묻는 사이 다 올리면 창을 닫고 다 올린 첨부를 저장한 뒤 떠난다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
final Completer<void> gate = holdUpload();
await attach(tester, <PickedFile>[picked('a.pdf')]);
await tapTooltip(tester, MailComposeTexts.close);
expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget);
gate.complete();
await tester.pumpAndSettle();
expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing);
expect(find.byType(MessageComposePage), findsNothing);
expect(c.saves.single.body['attachments'], <Object?>[
<String, Object?>{'id': 'up-1', 'name': 'a.pdf'},
]);
expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget);
});
testWidgets('세션이 끝나면 묻지 않고 올리던 것을 그만두고 복사본을 지운다 — 직원이 바뀌면 복사본 폴더도 비운다', (
WidgetTester tester,
) async {
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await start(tester);
await open(tester, MailRoutes.compose);
holdUpload();
final PickedFile file = picked('a.pdf');
await attach(tester, <PickedFile>[file]);
final int purges = h.picked.purgeCount;
unawaited(h.container.read(sessionProvider.notifier).signOut());
await tester.pumpAndSettle();
expect(find.byType(MessageComposePage), findsNothing);
expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing);
expect(h.picked.deleted, contains(file.path));
expect(h.picked.copies, isEmpty);
expect(h.picked.purgeCount, greaterThan(purges));
});
testWidgets('고르는 · 가져오는 동안 [보내기]는 까닭을 알리고 보내지 않는다 — 다 가져오면 그 파일과 함께 보낸다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
await addRecipient(tester, 'doyun@x.kr');
final Completer<void> gate = Completer<void>();
h.picked.pickGate = gate;
h.picked.willPick(<PickedFile>[picked('견적서.pdf')]);
await tester.tap(find.byTooltip(MailComposeTexts.attach));
await tester.pump();
await tester.tap(find.byTooltip(MailComposeTexts.send));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(snack(MailComposeTexts.attachmentsPicking), findsOneWidget);
expect(c.sends, isEmpty);
gate.complete();
await tester.pumpAndSettle();
expect(uploadedNames(), <String>['견적서.pdf']);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends.single.body['attachments'], <Object?>[
<String, Object?>{'id': 'up-1', 'name': '견적서.pdf'},
]);
});
testWidgets('고른 결과가 화면을 떠난 뒤에 오면 붙이지 않고 복사본만 지운다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
final Completer<void> gate = Completer<void>();
h.picked.pickGate = gate;
final PickedFile file = picked('견적서.pdf');
h.picked.willPick(<PickedFile>[file]);
await tester.tap(find.byTooltip(MailComposeTexts.attach));
await tester.pump();
await tapTooltip(tester, MailComposeTexts.close);
expect(find.byType(MessageComposePage), findsNothing);
gate.complete();
await tester.pumpAndSettle();
expect(h.picked.deleted, <String>[file.path!]);
expect(c.uploads, isEmpty);
});
testWidgets('고르는 사이 세션이 끝나면 고른 결과를 붙이지 않고 복사본만 지운다', (
WidgetTester tester,
) async {
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await start(tester);
await open(tester, MailRoutes.compose);
final Completer<void> gate = Completer<void>();
h.picked.pickGate = gate;
final PickedFile file = picked('견적서.pdf');
h.picked.willPick(<PickedFile>[file]);
await tester.tap(find.byTooltip(MailComposeTexts.attach));
await tester.pump();
unawaited(h.container.read(sessionProvider.notifier).signOut());
await tester.pumpAndSettle();
expect(find.byType(MessageComposePage), findsNothing);
gate.complete();
await tester.pumpAndSettle();
expect(h.picked.deleted, contains(file.path));
expect(c.uploads, isEmpty);
});
testWidgets(
'기기가 미리 거른 파일(형식 · 합계)은 개수 · 합계에 들지 않는다 — 18개에 HEIC · 견적서 · 큰도면 · 도면이면 두 PDF 를 넣는다(웹과 같다)',
(WidgetTester tester) async {
c.quota = quotaJson(totalMaxBytes: mb);
c.putDraft(
'd1',
attachments: <Map<String, Object?>>[
for (int i = 0; i < 18; i++)
attachmentJson('a$i', name: '자료$i.pdf', size: 10),
],
);
await start(tester);
await open(tester, MailRoutes.composeDraft('d1'));
// 기기(MainActivity.kt 의 copyPicked)가 같은 차례로 거른 결과 — HEIC 는 읽지 않았고, 큰도면은 합계를 넘겨 넣지 않았다
await attach(tester, <PickedFile>[
const PickedFile(
name: 'IMG_0001.HEIC',
status: PickedFileStatus.notAllowed,
size: 3,
),
picked('견적서.pdf', size: 600 * 1024),
const PickedFile(
name: '큰도면.pdf',
status: PickedFileStatus.overTotal,
size: 600 * 1024,
),
picked('도면.pdf', size: 300 * 1024),
]);
expectPick(maxBytes: 20 * mb, maxCount: 2, maxTotalBytes: mb - 180);
expect(
find.text(MailComposeTexts.attachmentsTitle(20)),
findsOneWidget,
);
expect(uploadedNames(), <String>['견적서.pdf', '도면.pdf']);
expect(
snack(
<String>[
ErrorLexicon.getMessage('FILE_001'),
'첨부를 모두 합치면 1MB를 넘어 ‘큰도면.pdf’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.',
].join('\n'),
),
findsOneWidget,
);
},
);
testWidgets(
'다 올리면 서버가 정리한 이름 · 크기로 싣고 보인다 — 255자 넘는 이름도 저장 · 발송이 거절되지 않게',
(WidgetTester tester) async {
await start(tester);
await open(tester, MailRoutes.compose);
await addRecipient(tester, 'doyun@x.kr');
final String longName = '${'가' * 300}.pdf';
final String serverName = serverFileName(longName);
c.uploadedSize = 6;
await attach(tester, <PickedFile>[picked(longName)]);
// 올릴 때는 고른 이름 그대로 보내고(서버가 줄인다), 다 올리면 서버의 이름으로
expect(c.uploads.single.name, longName);
expect(serverName.runes.length, 255);
expect(serverName, endsWith('.pdf'));
expect(find.text(serverName), findsOneWidget);
expect(find.text(longName), findsNothing);
expect(find.text(formatFileSize(6)), findsOneWidget);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends.single.body['attachments'], <Object?>[
<String, Object?>{'id': 'up-1', 'name': serverName},
]);
},
);
testWidgets('다시 올린 뒤 또 MAIL_004 면 "찾지 못함" — 빼기 전까지 보내기 · 저장을 막는다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
await attach(tester, <PickedFile>[picked('추가.pdf')]);
await addRecipient(tester, 'doyun@x.kr');
c.sendFailures.addAll(<FakeResponse>[
FakeResponse.fail(400, 'MAIL_004'),
FakeResponse.fail(400, 'MAIL_004'),
]);
await tapTooltip(tester, MailComposeTexts.send);
expect(uploadedNames(), <String>['추가.pdf', '추가.pdf']);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends, hasLength(2));
// 이미 한 번 다시 올렸다 — 또 올리지 않고 빼기 전까지 막는다
expect(uploadedNames(), hasLength(2));
expect(
inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_004'))),
findsOneWidget,
);
expect(find.text(MailComposeTexts.retryUpload), findsNothing);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends, hasLength(2));
expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget);
await tester.pump(const Duration(seconds: 30));
await tester.pumpAndSettle();
expect(c.saves, isEmpty);
await tapTooltip(tester, MailComposeTexts.remove('추가.pdf'));
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends, hasLength(3));
expect(c.sends.last.body['attachments'], isEmpty);
});
testWidgets('MAIL_004 뒤 복사본이 없어져 다시 올리지 못하면 빼기 전까지 보내지 않는다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
await attach(tester, <PickedFile>[picked('추가.pdf')]);
await addRecipient(tester, 'doyun@x.kr');
// 기기가 임시 폴더를 비웠다
h.picked.copies.clear();
c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004'));
await tapTooltip(tester, MailComposeTexts.send);
expect(uploadedNames(), hasLength(1));
expect(
inAttachments(findCopy(MailComposeTexts.uploadCopyGone)),
findsOneWidget,
);
expect(find.text(MailComposeTexts.retryUpload), findsNothing);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends, hasLength(1));
expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget);
});
testWidgets('MAIL_004 뒤 다시 올리기가 실패하면 [다시 시도]로 다 올릴 때까지 보내지 않는다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
await attach(tester, <PickedFile>[picked('추가.pdf')]);
await addRecipient(tester, 'doyun@x.kr');
c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004'));
c.uploadFailures.add(FakeResponse.fail(503, 'MAIL_001'));
await tapTooltip(tester, MailComposeTexts.send);
expect(uploadedNames(), hasLength(2));
expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget);
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends, hasLength(1));
expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget);
await tester.tap(find.text(MailComposeTexts.retryUpload));
await tester.pumpAndSettle();
expect(uploadedNames(), hasLength(3));
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends, hasLength(2));
expect(c.sends.last.body['attachments'], <Object?>[
<String, Object?>{'id': 'up-2', 'name': '추가.pdf'},
]);
});
testWidgets('떠난 뒤 기다린 저장이 고른 파일을 찾지 못하면(MAIL_004) 그 파일 없이 보내지 않고 알린다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
await attach(tester, <PickedFile>[picked('추가.pdf')]);
await addRecipient(tester, 'doyun@x.kr');
final Completer<void> gate = Completer<void>();
c.saveGate = gate;
await tester.pump(const Duration(seconds: 3));
expect(c.saves, hasLength(1));
await tester.tap(find.byTooltip(MailComposeTexts.send));
await tester.pump();
// 보내는 중 — 묻지 않고 떠난다(올리기 차례 · 복사본이 닫혀 다시 올릴 수 없다)
await tapTooltip(tester, MailComposeTexts.close);
expect(find.byType(MessageComposePage), findsNothing);
c.draftFailures.add(FakeResponse.fail(400, 'MAIL_004'));
gate.complete();
await tester.pumpAndSettle();
expect(c.sends, isEmpty);
expect(h.notifier.shownCodes, <String>['MAIL_004']);
expect(c.saves, hasLength(1));
expect(uploadedNames(), hasLength(1));
});
testWidgets('묻는 사이 마지막 올리기가 실패하면 창을 닫지 않고 답을 기다린다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
final Completer<void> gate = holdUpload();
await attach(tester, <PickedFile>[picked('a.pdf')]);
await tapTooltip(tester, MailComposeTexts.close);
expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget);
c.uploadFailures.add(FakeResponse.fail(503, 'MAIL_001'));
gate.complete();
await tester.pumpAndSettle();
expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget);
expect(find.byType(MessageComposePage), findsOneWidget);
await tester.tap(find.text(MailComposeTexts.stay));
await tester.pumpAndSettle();
expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget);
expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing);
});
testWidgets('답장 · 첨부 없는 저장본은 첨부 한도를 기다리지 않고 연다', (
WidgetTester tester,
) async {
h.mail.put(
kInboxId,
messageJson('r1', mailboxId: kInboxId, subject: '안부', isRead: true),
);
c.putDraft('d2', subject: '쓰던 메일');
await start(tester);
c.quotaGate = Completer<void>();
unawaited(h.router.push<void>(MailRoutes.composeReply('r1')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(textOf(tester, subjectField), 'Re: 안부');
await tapTooltip(tester, MailComposeTexts.close);
expect(find.byType(MessageComposePage), findsNothing);
c.quotaGate = Completer<void>();
unawaited(h.router.push<void>(MailRoutes.composeDraft('d2')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(textOf(tester, subjectField), '쓰던 메일');
});
testWidgets(
'기기가 답하지 않는 고르기는 시한(10분) 뒤 풀린다 — 알리고 [보내기] · [파일 첨부]가 다시 되고 다음 고르기가 묶이지 않는다',
(WidgetTester tester) async {
// 기기 채널 쪽 차례 · 시한까지 보려고 실제 보관소를 쓴다 — 첫 고르기는 답이 오지 않는다(고른 앱이 멈춤)
final List<MethodCall> channelCalls = <MethodCall>[];
final Completer<Object?> stuck = Completer<Object?>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(PlatformFileOpener.channel, (
MethodCall call,
) {
channelCalls.add(call);
return channelCalls.length == 1
? stuck.future
: Future<Object?>.value(<String, Object?>{
'outcome': 'canceled',
});
});
addTearDown(
() => TestDefaultBinaryMessengerBinding
.instance
.defaultBinaryMessenger
.setMockMethodCallHandler(PlatformFileOpener.channel, null),
);
h.pickedStore = PlatformPickedFileStore(
baseDirectory: () async => Directory.systemTemp,
);
final Finder pickingBar = find.byWidgetPredicate(
(Widget widget) =>
widget is LinearProgressIndicator &&
widget.semanticsLabel == MailComposeTexts.picking,
);
final Finder attachButton = find.widgetWithIcon(
IconButton,
Icons.attach_file,
);
await start(tester);
await open(tester, MailRoutes.compose);
await addRecipient(tester, 'doyun@x.kr');
await tester.tap(find.byTooltip(MailComposeTexts.attach));
await tester.pump();
expect(pickingBar, findsOneWidget);
expect(tester.widget<IconButton>(attachButton).onPressed, isNull);
await tester.pump(kPickTimeout);
await tester.pumpAndSettle();
expect(pickingBar, findsNothing);
expect(snack(MailComposeTexts.pickReadFailed), findsOneWidget);
expect(tester.widget<IconButton>(attachButton).onPressed, isNotNull);
// 다음 고르기는 시한으로 끝난 고르기 뒤에 묶이지 않는다
await tapTooltip(tester, MailComposeTexts.attach);
expect(channelCalls, hasLength(2));
expect(pickingBar, findsNothing);
// [보내기]도 다시 된다
await tapTooltip(tester, MailComposeTexts.send);
expect(c.sends, hasLength(1));
},
);
testWidgets('고르는 동안 위에 진행 막대 · [파일 첨부]는 막힌다 — 고르지 못하면 알린다', (
WidgetTester tester,
) async {
await start(tester);
await open(tester, MailRoutes.compose);
final Completer<void> gate = Completer<void>();
h.picked.pickGate = gate;
final Finder pickingBar = find.byWidgetPredicate(
(Widget widget) =>
widget is LinearProgressIndicator &&
widget.semanticsLabel == MailComposeTexts.picking,
);
final Finder attachButton = find.widgetWithIcon(
IconButton,
Icons.attach_file,
);
await tester.tap(find.byTooltip(MailComposeTexts.attach));
await tester.pump();
expect(pickingBar, findsOneWidget);
expect(tester.widget<IconButton>(attachButton).onPressed, isNull);
gate.complete();
await tester.pumpAndSettle();
expect(pickingBar, findsNothing);
expect(tester.widget<IconButton>(attachButton).onPressed, isNotNull);
expect(find.byType(SnackBar), findsNothing);
h.picked.results.add(const PickResult(PickOutcome.failed));
await tapTooltip(tester, MailComposeTexts.attach);
expect(snack(MailComposeTexts.pickFailed), findsOneWidget);
});
});
}