generated from ttipo/project-base
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user