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,338 @@
import 'dart:async';
import 'dart:io';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/file_names.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 고른 파일(`PlatformPickedFileStore`) — 채널의 답 읽기 · 복사본 폴더 안에서만 지우기 · 차례.
/// 채널은 시험의 가짜 처리기로, 복사본은 시험마다 만든 임시 폴더(실제 파일 입출력)로 본다 — Kotlin 이 만드는 자리와 같은 꼴
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const MethodChannel channel = PlatformFileOpener.channel;
late Directory base;
late PlatformPickedFileStore store;
final List<MethodCall> calls = <MethodCall>[];
/// 채널의 답 — 시험이 바꿔 끼운다
late Future<Object?> Function(MethodCall call) answer;
setUp(() async {
base = await Directory.systemTemp.createTemp('arasystem-picked-test-');
store = PlatformPickedFileStore(baseDirectory: () async => base);
calls.clear();
answer = (MethodCall call) async => <String, Object?>{
'outcome': 'canceled',
};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (MethodCall call) {
calls.add(call);
return answer(call);
});
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
if (await base.exists()) await base.delete(recursive: true);
});
String rootPath() => '${base.path}/$kPickedFilesFolder';
/// Kotlin 이 만드는 복사본 — `uploads/<무작위>/<이름>`
Future<String> copyOf(String folder, String name, List<int> bytes) async {
final File file = File('${rootPath()}/$folder/$name');
await file.create(recursive: true);
await file.writeAsBytes(bytes);
return file.path;
}
group('채널의 답 읽기', () {
test('고른 차례대로 — 다 복사한 것 · 크기 초과 · 가져오지 않음 · 형식 · 합계로 거름 · 읽지 못함', () {
final PickResult result = PickResult.parse(<Object?, Object?>{
'outcome': 'picked',
'files': <Object?>[
<Object?, Object?>{
'name': '견적서.pdf',
'size': 3,
'status': 'ok',
'path': '/c/uploads/a/견적서.pdf',
},
<Object?, Object?>{
'name': '영상.mp4',
'size': 900000000,
'status': 'tooLarge',
'path': null,
},
<Object?, Object?>{
'name': 'c.png',
'size': null,
'status': 'skipped',
},
<Object?, Object?>{
'name': 'IMG_0001.HEIC',
'size': 3,
'status': 'notAllowed',
},
<Object?, Object?>{
'name': '도면.pdf',
'size': 9,
'status': 'overTotal',
},
<Object?, Object?>{'name': null, 'status': 'failed'},
],
});
expect(result.outcome, PickOutcome.picked);
expect(result.files, <PickedFile>[
const PickedFile(
name: '견적서.pdf',
status: PickedFileStatus.ok,
size: 3,
path: '/c/uploads/a/견적서.pdf',
),
const PickedFile(
name: '영상.mp4',
status: PickedFileStatus.tooLarge,
size: 900000000,
),
const PickedFile(name: 'c.png', status: PickedFileStatus.skipped),
const PickedFile(
name: 'IMG_0001.HEIC',
status: PickedFileStatus.notAllowed,
size: 3,
),
const PickedFile(
name: '도면.pdf',
status: PickedFileStatus.overTotal,
size: 9,
),
const PickedFile(
name: kFallbackFileName,
status: PickedFileStatus.failed,
),
]);
});
test('다 복사했다는데 경로 · 크기가 없거나 모양이 틀리면 읽지 못한 파일로 본다', () {
final PickResult result = PickResult.parse(<Object?, Object?>{
'outcome': 'picked',
'files': <Object?>[
<Object?, Object?>{'name': 'a.pdf', 'size': 3, 'status': 'ok'},
<Object?, Object?>{
'name': 'b.pdf',
'status': 'ok',
'path': '/c/uploads/b/b.pdf',
},
<Object?, Object?>{'name': 'c.pdf', 'status': 'unknown'},
'모양이 틀림',
],
});
expect(
result.files.map((PickedFile file) => file.status),
everyElement(PickedFileStatus.failed),
);
expect(result.files.first.name, 'a.pdf');
});
test('고르지 않음 · 열려 있음 · 앱 없음 · 실패 · 모르는 답', () {
PickOutcome outcomeOf(Object? raw) => PickResult.parse(raw).outcome;
expect(
outcomeOf(<Object?, Object?>{'outcome': 'canceled'}),
PickOutcome.canceled,
);
expect(
outcomeOf(<Object?, Object?>{'outcome': 'busy'}),
PickOutcome.busy,
);
expect(
outcomeOf(<Object?, Object?>{'outcome': 'noApp'}),
PickOutcome.noApp,
);
expect(
outcomeOf(<Object?, Object?>{'outcome': 'failed'}),
PickOutcome.failed,
);
expect(outcomeOf(<Object?, Object?>{'outcome': '?'}), PickOutcome.failed);
expect(outcomeOf(null), PickOutcome.failed);
expect(
outcomeOf(<Object?, Object?>{'outcome': 'picked', 'files': 3}),
PickOutcome.failed,
);
});
test('보이는 이름 — 경로 앞부분 · 제어 · 방향 제어 글자를 지우고 앞뒤 공백을 지운다(다른 글자는 그대로)', () {
expect(displayFileName(' 회의:결과.pdf '), '회의:결과.pdf');
expect(displayFileName('a/b\\보고서.hwp'), '보고서.hwp');
expect(displayFileName('invoice\u202Etxt.exe'), 'invoicetxt.exe');
expect(displayFileName('a\u0000b\nc.png'), 'abc.png');
expect(displayFileName('\u200B '), kFallbackFileName);
expect(displayFileName(null), kFallbackFileName);
});
});
group('플랫폼 고르기', () {
test('파일 채널의 pick — 남은 개수 · 파일당 · 남은 합계 · 허용 확장자를 넘기고 답을 읽는다', () async {
answer = (MethodCall call) async => <String, Object?>{
'outcome': 'picked',
'files': <Object?>[
<String, Object?>{
'name': 'a.pdf',
'size': 1,
'status': 'ok',
'path': '${rootPath()}/x/a.pdf',
},
],
};
final PickResult result = await store.pick(
maxBytes: 20,
maxCount: 3,
maxTotalBytes: 25,
extensions: <String>['pdf', 'hwp'],
);
expect(calls.single.method, PlatformPickedFileStore.pickMethod);
expect(calls.single.arguments, <String, Object?>{
'maxBytes': 20,
'maxCount': 3,
'maxTotalBytes': 25,
'extensions': <String>['pdf', 'hwp'],
});
expect(result.files.single.path, '${rootPath()}/x/a.pdf');
});
test(
'기기가 답하지 않으면 시한 뒤 timedOut 으로 끝내고 차례를 넘긴다 — 다음 고르기 · 정리가 묶이지 않는다',
() async {
// 고른 앱이 읽기에서 멈춰 기기가 답하지 않는다 — 첫 고르기의 답은 오지 않는다
final Completer<Object?> stuck = Completer<Object?>();
answer = (MethodCall call) => calls.length == 1
? stuck.future
: Future<Object?>.value(<String, Object?>{'outcome': 'canceled'});
final PlatformPickedFileStore quick = PlatformPickedFileStore(
baseDirectory: () async => base,
pickTimeout: const Duration(milliseconds: 20),
);
Future<PickResult> pick() => quick.pick(
maxBytes: 1,
maxCount: 1,
maxTotalBytes: 1,
extensions: <String>['pdf'],
);
expect((await pick()).outcome, PickOutcome.timedOut);
expect((await pick()).outcome, PickOutcome.canceled);
expect(calls, hasLength(2));
await copyOf('f1', 'a.pdf', <int>[1]);
await quick.purge();
expect(await Directory(rootPath()).exists(), isFalse);
// 늦게 온 답은 버린다(그 복사본은 다음 정리에 지워진다)
stuck.complete(<String, Object?>{'outcome': 'canceled'});
expect(kPickTimeout, const Duration(minutes: 10));
},
);
test('채널이 없거나(iOS) 던지면 실패', () async {
answer = (MethodCall call) async => throw PlatformException(code: 'boom');
expect(
(await store.pick(
maxBytes: 1,
maxCount: 1,
maxTotalBytes: 1,
extensions: <String>['pdf'],
)).outcome,
PickOutcome.failed,
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
expect(
(await store.pick(
maxBytes: 1,
maxCount: 1,
maxTotalBytes: 1,
extensions: <String>['pdf'],
)).outcome,
PickOutcome.failed,
);
});
test('모두 지우기는 열린 고르기가 끝난 뒤에 돈다 — 고르는 동안 들어온 정리가 막 고른 복사본까지 지운다', () async {
final Completer<Object?> picker = Completer<Object?>();
answer = (MethodCall call) => picker.future;
final Future<PickResult> picking = store.pick(
maxBytes: 9,
maxCount: 1,
maxTotalBytes: 9,
extensions: <String>['pdf'],
);
final Future<void> purging = store.purge();
await Future<void>.delayed(Duration.zero);
// 고르는 창이 열린 동안 복사본이 생긴다(Kotlin)
final String path = await copyOf('f1', 'a.pdf', <int>[1]);
bool purged = false;
unawaited(purging.then((_) => purged = true));
await Future<void>.delayed(Duration.zero);
expect(purged, isFalse);
expect(await File(path).exists(), isTrue);
picker.complete(<String, Object?>{'outcome': 'canceled'});
await picking;
await purging;
expect(await Directory(rootPath()).exists(), isFalse);
});
});
group('복사본', () {
test('있는가 · 읽기 · 지우기 — 복사본 폴더 바로 아래 한 칸만, 지우면 그 폴더째', () async {
final String path = await copyOf('f1', '견적서.pdf', <int>[1, 2, 3]);
final String other = await copyOf('f2', '견적서.pdf', <int>[4]);
expect(await store.exists(path), isTrue);
expect(
await store.openRead(path).expand((List<int> chunk) => chunk).toList(),
<int>[1, 2, 3],
);
await store.delete(path);
expect(await store.exists(path), isFalse);
expect(await Directory('${rootPath()}/f1').exists(), isFalse);
expect(await store.exists(other), isTrue);
// 이미 없어도 된다
await store.delete(path);
});
test('복사본 폴더 밖 · 폴더 깊숙한 곳은 있다고 보지 않고 지우지 않는다', () async {
final File outside = File('${base.path}/downloads/x/a.pdf');
await outside.create(recursive: true);
final File deep = File('${rootPath()}/f1/deeper/a.pdf');
await deep.create(recursive: true);
final File direct = File('${rootPath()}/a.pdf');
await direct.create(recursive: true);
for (final File file in <File>[outside, deep, direct]) {
expect(await store.exists(file.path), isFalse, reason: file.path);
await store.delete(file.path);
expect(await file.exists(), isTrue, reason: file.path);
}
// ../ 로 빠져나가는 경로도
final String escaping = '${rootPath()}/f1/../../downloads/x/a.pdf';
expect(await store.exists(escaping), isFalse);
await store.delete(escaping);
expect(await outside.exists(), isTrue);
});
test('purge — 복사본 폴더를 모두 지운다(없어도 된다) · 임시 폴더를 모르면 조용히 넘어간다', () async {
await copyOf('f1', 'a.pdf', <int>[1]);
await store.purge();
expect(await Directory(rootPath()).exists(), isFalse);
await store.purge();
final PlatformPickedFileStore broken = PlatformPickedFileStore(
baseDirectory: () async => throw MissingPluginException('시험'),
);
await broken.purge();
expect(await broken.exists('${rootPath()}/f1/a.pdf'), isFalse);
await broken.delete('${rootPath()}/f1/a.pdf');
});
});
}
@@ -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);
});
});
}
+80 -1
View File
@@ -11,7 +11,7 @@ import 'mail_fakes.dart';
typedef SentRequest = ({Map<String, Object?> body, String? key});
/// 쓰기 화면의 API 를 흉내 내는 가짜 뒤쪽 — 임시 저장(POST · PUT · GET) · 발송 · 서명 · 서명 그림 · 보내기 설정 ·
/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태.
/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태 · 첨부 올리기 · 첨부 한도.
///
/// 저장본은 메모리에 둔다 — PUT 은 새 id 로 바꾸고 옛것을 지운다(계약 2절). 실패([draftFailure] · [sendFailure])와
/// 문([saveGate] · [sendGate])으로 응답을 늦추거나 실패시킨다.
@@ -74,8 +74,28 @@ class FakeComposeBackend {
/// 있으면 대용량 파일 상태 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다)
Completer<void>? largeFileGate;
/// 받은 첨부 올리기 차례대로 — multipart 의 이름 · 형식 · 길이
final List<({String name, String type, int length})> uploads =
<({String name, String type, int length})>[];
/// 다음 올리기들이 이 응답으로 실패한다(차례대로 하나씩 쓴다)
final List<FakeResponse> uploadFailures = <FakeResponse>[];
/// 올리기 응답이 차례대로 이것들이 열릴 때까지 기다린다(온 차례대로 하나씩 쓴다 — 없으면 바로 답한다)
final List<Completer<void>> uploadGates = <Completer<void>>[];
/// 첨부 한도 응답(`MailQuota`) — null 이면 받지 못한다(503 `MAIL_001`)
Map<String, Object?>? quota = quotaJson();
/// 있으면 올리기 응답의 크기를 이것으로 준다(서버가 센 크기 — 없으면 보낸 길이)
int? uploadedSize;
/// 있으면 첨부 한도 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다)
Completer<void>? quotaGate;
int _nextDraft = 0;
int _nextSent = 0;
int _nextUpload = 0;
static final RegExp _draftPath = RegExp(r'^/drafts/([^/]+)$');
static final RegExp _imagePath = RegExp(r'^/mail-signature/images/([^/]+)$');
@@ -185,6 +205,18 @@ class FakeComposeBackend {
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
});
});
server.on('POST', ComposeApiPaths.mailAttachments, _upload);
server.on('GET', ComposeApiPaths.mailQuota, (_) async {
final Completer<void>? gate = quotaGate;
if (gate != null) {
quotaGate = null;
await gate.future;
}
final Map<String, Object?>? current = quota;
return current == null
? FakeResponse.fail(503, 'MAIL_001')
: FakeResponse.ok(current);
});
server.onPattern('GET', _filePath, (RequestOptions options) async {
final Completer<void>? gate = largeFileGate;
if (gate != null) {
@@ -246,6 +278,27 @@ class FakeComposeBackend {
});
}
Future<FakeResponse> _upload(RequestOptions options) async {
final MultipartFile file = (options.data! as FormData).files.single.value;
final String name = file.filename ?? '';
uploads.add((
name: name,
type: file.contentType.toString(),
length: file.length,
));
if (uploadGates.isNotEmpty) await uploadGates.removeAt(0).future;
if (uploadFailures.isNotEmpty) return uploadFailures.removeAt(0);
return FakeResponse(201, <String, Object?>{
'success': true,
'data': attachmentJson(
'up-${++_nextUpload}',
name: serverFileName(name),
type: file.contentType.toString(),
size: uploadedSize ?? file.length,
),
});
}
Future<FakeResponse> _send(RequestOptions options) async {
final Map<String, Object?> body = options.data! as Map<String, Object?>;
sends.add((
@@ -296,3 +349,29 @@ class FakeComposeBackend {
});
}
}
/// 첨부 한도 응답(`MailQuota`) — 기본은 서버 기본값(20 · 25MB)
Map<String, Object?> quotaJson({
int maxBytes = 20 * 1024 * 1024,
int totalMaxBytes = 25 * 1024 * 1024,
}) => <String, Object?>{
'usedBytes': 0,
'limitBytes': null,
'attachmentMaxBytes': maxBytes,
'attachmentTotalMaxBytes': totalMaxBytes,
};
/// 서버가 받은 첨부 이름을 줄이는 모양 — 백엔드 `shared/utils/file-name.ts` 의 `sanitizeFileName` 처럼 255 코드 포인트까지, 확장자는 남긴다
String serverFileName(String name) {
const int maxLength = 255;
final List<int> runes = name.runes.toList();
if (runes.length <= maxLength) return name;
final int dot = name.lastIndexOf('.');
final List<int> extension = dot > 0
? name.substring(dot).runes.toList()
: <int>[];
return String.fromCharCodes(<int>[
...runes.take(maxLength - extension.length),
...extension,
]);
}
+13
View File
@@ -227,6 +227,14 @@ class FakeServer implements HttpClientAdapter {
/// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다
DateTime? serverTime;
/// 켜면 처리기를 부르기 전에 요청 본문을 끝까지 읽어 [requestBodies] 에 둔다 — 보내는 진행(onSendProgress)이 오고,
/// multipart 본문을 바이트로 볼 수 있다(꺼 두면 본문은 `RequestOptions.data` 로만 본다)
bool readRequestBodies = false;
/// 읽은 요청 본문 — [readRequestBodies] 를 켠 동안 받은 차례대로
final List<({String path, List<int> body})> requestBodies =
<({String path, List<int> body})>[];
/// 경로 꼴마다 처리기 — 같은 경로의 [on] 이 없을 때 먼저 맞는 것
final List<({String method, RegExp path, FakeHandler handler})> _patterns =
<({String method, RegExp path, FakeHandler handler})>[];
@@ -266,6 +274,11 @@ class FakeServer implements HttpClientAdapter {
) async {
// 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게
requests.add(options.copyWith());
if (readRequestBodies && requestStream != null) {
final List<int> body = <int>[];
await requestStream.forEach(body.addAll);
requestBodies.add((path: options.path, body: body));
}
final FakeHandler? handler = _handlerOf(options);
final FakeResponse response = handler == null
? FakeResponse.fail(404, 'RES_001')
+101
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/file_names.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
/// 메모리 파일 보관 — 시험에서는 기기 파일 시스템을 쓰지 않는다(시험 시계 안에서 파일 입출력이 끝나지 않는다)
class MemoryFileStore implements DownloadedFileStore {
@@ -94,3 +95,103 @@ class RecordingLinkLauncher {
return result;
}
}
/// 고른 파일의 가짜 — 고르기의 결과를 차례대로 준다([willPick] · [results] — 비면 고르지 않고 닫은 것).
/// 복사본은 메모리(경로마다 바이트)다 — 시험 시계 안에서는 기기 파일 입출력이 끝나지 않는다
class FakePickedFiles implements PickedFileStore {
/// 다음 고르기들의 결과 — 차례대로 하나씩 쓴다
final List<PickResult> results = <PickResult>[];
/// 받은 고르기 — 넘겨받은 한도들(남은 개수 · 파일당 · 남은 합계 · 허용 확장자)
final List<
({int maxBytes, int maxCount, int maxTotalBytes, List<String> extensions})
>
picks =
<
({
int maxBytes,
int maxCount,
int maxTotalBytes,
List<String> extensions,
})
>[];
/// 있으면 고르기가 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) — 고르는 창이 열려 있는 동안
Completer<void>? pickGate;
/// 복사본 — 경로마다 바이트
final Map<String, List<int>> copies = <String, List<int>>{};
/// 지운 복사본 차례대로
final List<String> deleted = <String>[];
int purgeCount = 0;
int _next = 0;
/// 고른 파일 하나 — 복사본을 두고 그 [PickedFile] 을 준다
PickedFile copy(String name, List<int> bytes) {
final String path = '/cache/$kPickedFilesFolder/${++_next}/$name';
copies[path] = bytes;
return PickedFile(
name: name,
status: PickedFileStatus.ok,
size: bytes.length,
path: path,
);
}
/// 다음 고르기에서 이 파일들을 고른다 — 기기가 미리 거른 결과(`MainActivity.kt` 의 `copyPicked`)를 그대로 적는다
void willPick(List<PickedFile> files) =>
results.add(PickResult(PickOutcome.picked, files));
@override
Future<PickResult> pick({
required int maxBytes,
required int maxCount,
required int maxTotalBytes,
required List<String> extensions,
}) async {
picks.add((
maxBytes: maxBytes,
maxCount: maxCount,
maxTotalBytes: maxTotalBytes,
extensions: extensions,
));
final Completer<void>? gate = pickGate;
if (gate != null) {
pickGate = null;
await gate.future;
}
return results.isEmpty
? const PickResult(PickOutcome.canceled)
: results.removeAt(0);
}
@override
Future<bool> exists(String path) async => copies.containsKey(path);
/// 조각 넷으로 흘린다 — 보내는 진행이 여러 번 온다
@override
Stream<List<int>> openRead(String path) {
final List<int>? bytes = copies[path];
if (bytes == null) {
return Stream<List<int>>.error(const FileSystemException('없음(시험)'));
}
final int step = (bytes.length / 4).ceil().clamp(1, bytes.length + 1);
return Stream<List<int>>.fromIterable(<List<int>>[
for (int start = 0; start < bytes.length; start += step)
bytes.sublist(start, (start + step).clamp(0, bytes.length)),
]);
}
@override
Future<void> delete(String path) async {
deleted.add(path);
copies.remove(path);
}
@override
Future<void> purge() async {
purgeCount++;
copies.clear();
}
}
+7 -1
View File
@@ -1,6 +1,7 @@
import 'package:arasystem_app/app.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/core/router/app_router.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart';
@@ -27,7 +28,7 @@ const Size kPhoneSize = Size(390, 844);
/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다.
///
/// 기기에 기대는 것은 가짜로 바꾼다: WebView([webView]), 받은 파일 보관([files]) · 다른 앱으로 열기([opener]),
/// 바깥 브라우저([links]).
/// 바깥 브라우저([links]), 고른 파일([picked] — 고르는 창 · 복사본).
class MailAppHarness {
MailAppHarness();
@@ -37,6 +38,10 @@ class MailAppHarness {
final MemoryFileStore files = MemoryFileStore();
final RecordingFileOpener opener = RecordingFileOpener();
final RecordingLinkLauncher links = RecordingLinkLauncher();
final FakePickedFiles picked = FakePickedFiles();
/// 고른 파일을 두는 곳 — 처음은 [picked](가짜). 기기 채널 쪽 규칙(시한 등)을 보는 시험은 [pump] 전에 바꿔 끼운다
late PickedFileStore pickedStore = picked;
/// 시험마다 더하는 덮어쓰기 — [pump] 전에 채운다
final List<Override> overrides = <Override>[];
@@ -82,6 +87,7 @@ class MailAppHarness {
downloadedFileStoreProvider.overrideWithValue(files),
fileOpenerProvider.overrideWithValue(opener),
mailLinkLauncherProvider.overrideWithValue(links.call),
pickedFileStoreProvider.overrideWithValue(pickedStore),
...overrides,
],
child: const App(),