From 62784039e4601d8bbf479e0bdc0e0045b447d310 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:15:22 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20P0=20=EC=9B=90=EA=B2=A9=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=94=EA=B8=B0:=20--delete=20=EA=B0=80=20=EC=97=86=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EB=B0=9B=EB=8A=94=20=EC=AA=BD=EC=9D=98=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9D=B4=EB=A6=84=20=ED=8F=B4=EB=8D=94=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A=EC=9D=8C=20(=EC=98=88?= =?UTF-8?q?=EC=A0=84:=20=ED=86=B5=EC=A7=B8=EB=A1=9C=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B3=A0=20=ED=8C=8C=EC=9D=BC=EB=A1=9C)=20=C2=B7=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=EC=97=90=20=EB=B3=B4=EC=9E=84=20?= =?UTF-8?q?(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/19, 15 의 원격 동기화 검토 M1 - P0): WebDAV · SFTP · SMB · FTP 가 끼면 앱이 직접 맞추는데 (file_ops._mirrorV), 원본은 파일 X, 받는 쪽에는 같은 이름의 **폴더** X 가 있으면 --delete 가 없어도 그 폴더를 통째로 지우고 파일로 바꿨다. 미리 보기에는 "바뀜" 한 줄로만 보여 사용자가 모르게 지워졌다 (10/8 WebDAV 부터 정식 판에 있던 것으로 봄). 실제 rsync 는 --delete (또는 --force) 없이는 비어 있지 않은 폴더를 지우지 않는다. - _mirrorV: --delete 가 없으면 그 이름은 건너뛰고 FileOps.dirConflicts 에 남긴다. --delete 면 지금처럼 지우고 파일로. - 미리 보기 (sync_preview): 지우기 없음 → "받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로" (주황, 새 PreviewAction.skipDir), 지우기 → 지울 목록에 "폴더 · 안의 n개를 지우고 같은 이름의 파일로" (빨강, 지울 수에 들어가 72 의 보호를 받음). - 알림: 복사 · 이동 작업 끝 "같은 이름의 폴더가 있어 건너뜀 n개: …", 실시간 동기화 (lsync, 원격 쌍) 카드 "n개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 n개" (같은 규칙). - 로컬끼리 맞추기는 원래 폴더를 지우지 않았다 (파일 이름 바꾸기가 실패) - 그대로. 시험: webdav_test · sftp_test (새 각 1, 시험 WebDAV 서버 · paramiko SFTP 서버): 지우기 없음 → 미리 보기 skipDir · 지울 것 없음 · 실제로 폴더와 안의 파일 그대로 · 다른 파일은 맞춤 · dirConflicts 1 / 지우기 → 미리 보기 지우기 줄 (안의 n개) · 실제로 파일로. analyze 문제 없음, l10n 빠짐 0. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 5 + assets/l10n/ja.json | 5 + assets/l10n/zh-Hans.json | 5 + lib/app/live_sync.dart | 9 +- lib/app/transfer_job.dart | 5 + lib/core/file_ops.dart | 14 +- lib/core/sync_preview.dart | 42 ++- lib/ui/explorer_page.dart | 2 + lib/ui/sync_preview_view.dart | 9 +- test/sftp_test.dart | 519 ++++++++++++++++++---------------- test/webdav_test.dart | 413 ++++++++++++++------------- 11 files changed, 580 insertions(+), 448 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 6f6893a..4a6267d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -59,11 +59,13 @@ "(덮어씀)": "(overwritten)", "(동영상을 세 번 누르면 옮깁니다)": "(Tapping a video three times moves it)", "(링크 · junction - 따라가지 않고 건너뜀)": "(link · junction - not followed, skipped)", + "(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(skipped - the destination has a folder with the same name; the folder is kept)", "(받는 쪽에만 있음)": "(only on receiving side)", "(받는 쪽이 더 새것 - 건너뜀)": "(receiving side is newer - skipped)", "(비어 있음)": "(empty)", "(설명 없음)": "(no description)", "(지워짐)": "(deleted)", + "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(folder and the {0} items inside are deleted and replaced by the file of the same name)", "(현재 방식은 옵션 없음)": "(no options for the built-in method)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* matches any text, ? one character. Separate several with ; (e.g. *.srt; *.ass)", "+ 여기에 새 줄": "+ New line here", @@ -546,6 +548,7 @@ "{0}개 고름": "{0} selected", "{0}개 더 보기": "Show {0} more", "{0}개 맞춤": "{0} synced", + "{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "{0} synced · skipped {1} (a folder with the same name exists)", "{0}개 목록에서 숨기기": "Hide {0} items from list", "{0}개 보임": "{0} shown", "{0}개 삭제": "Delete {0} items", @@ -657,6 +660,8 @@ "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "Can't create the folder because a file with the same name exists: {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}": "Skipped {0} (a folder with the same name exists)", + "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "Skipped {0} because a folder with the same name exists: {1}", "같은 이름이 이미 있습니다": "An item with the same name already exists", "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", "같은 이름이 이미 있음": "Name already exists", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 5dbd030..64307fa 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -59,11 +59,13 @@ "(덮어씀)": "(上書き)", "(동영상을 세 번 누르면 옮깁니다)": "(動画を 3 回タップすると移動します)", "(링크 · junction - 따라가지 않고 건너뜀)": "(リンク · ジャンクション - たどらずにスキップ)", + "(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(受け取る側に同名のフォルダーがあるためスキップ - フォルダーはそのまま)", "(받는 쪽에만 있음)": "(受け取る側にだけある)", "(받는 쪽이 더 새것 - 건너뜀)": "(受け取る側が新しい - スキップ)", "(비어 있음)": "(空)", "(설명 없음)": "(説明なし)", "(지워짐)": "(削除)", + "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(フォルダーと中の {0} 件を削除して同名のファイルに)", "(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* は任意の文字列、? は 1 文字。複数は ; で区切る (例: *.srt; *.ass)", "+ 여기에 새 줄": "+ ここに新しい行", @@ -546,6 +548,7 @@ "{0}개 고름": "{0} 件選択", "{0}개 더 보기": "さらに {0} 件表示", "{0}개 맞춤": "{0} 件を同期", + "{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "{0} 件同期 · 同名のフォルダーがあるため {1} 件スキップ", "{0}개 목록에서 숨기기": "{0} 件をリストから隠す", "{0}개 보임": "{0} 件表示", "{0}개 삭제": "{0} 件を削除", @@ -657,6 +660,8 @@ "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}": "同名のフォルダーがあるためスキップ {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "同名のフォルダーがあるため {0} 件スキップ: {1}", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", "같은 이름이 이미 있음": "同じ名前が既にあります", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 18f44af..10aedc3 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -59,11 +59,13 @@ "(덮어씀)": "(覆盖)", "(동영상을 세 번 누르면 옮깁니다)": "(连按视频三次会移动它)", "(링크 · junction - 따라가지 않고 건너뜀)": "(链接 · 联接 - 不跟随,已跳过)", + "(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(已跳过 - 目标有同名文件夹,文件夹保持不变)", "(받는 쪽에만 있음)": "(仅在接收方)", "(받는 쪽이 더 새것 - 건너뜀)": "(接收端较新 - 已跳过)", "(비어 있음)": "(空)", "(설명 없음)": "(无说明)", "(지워짐)": "(将删除)", + "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(删除文件夹及其中 {0} 项,换成同名文件)", "(현재 방식은 옵션 없음)": "(当前方式没有选项)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* 表示任意字符,? 表示一个字符。多个用 ; 分隔(例:*.srt; *.ass)", "+ 여기에 새 줄": "+ 在此新增一行", @@ -546,6 +548,7 @@ "{0}개 고름": "已选 {0} 个", "{0}개 더 보기": "再显示 {0} 个", "{0}개 맞춤": "已同步 {0} 个", + "{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "已同步 {0} 项 · 因同名文件夹跳过 {1} 项", "{0}개 목록에서 숨기기": "从列表中隐藏 {0} 项", "{0}개 보임": "显示 {0} 项", "{0}개 삭제": "删除 {0} 项", @@ -657,6 +660,8 @@ "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}": "因存在同名文件夹而跳过 {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "因存在同名文件夹跳过 {0} 项: {1}", "같은 이름이 이미 있습니다": "已存在同名项目", "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", "같은 이름이 이미 있음": "同名项已存在", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 5d5ab2a..137898f 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -416,15 +416,18 @@ class LiveSync extends ChangeNotifier { if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty); // 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교) final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method); + // M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다 + final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps); final n = switch (method) { - CopyMethod.builtin => await FileOps(bandwidthKBps: s.copyBandwidthKBps) - .mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty), + CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty), CopyMethod.rsync => await _rsync(x, s), CopyMethod.robocopy => await _robocopy(x, s), }; status[k] = toDelete[k] != null ? (DateTime.now(), trf('지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요', [toDelete[k]!.length])) - : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); + : ops.dirConflicts.isNotEmpty + ? (DateTime.now(), trf('{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개', [n < 0 ? 0 : n, ops.dirConflicts.length])) + : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); problems.remove(k); emptyDeletes.remove(k); pending[k] = await diff(asked); diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 6ac6a8b..a10b640 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -100,6 +100,9 @@ class TransferJob extends ChangeNotifier { /// 같은 이름이라 건너뛴 항목 (원본 경로) final skipped = []; + /// M1 (10/19): 원격 맞추기에서 받는 쪽에 같은 이름의 폴더가 있어 건너뛴 것 (--delete 가 없을 때) + final dirConflicts = []; + TransferJob({ required this.sources, required this.dest, @@ -341,6 +344,8 @@ class TransferJob extends ChangeNotifier { // 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도 settled.clear(); await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move); + dirConflicts.addAll(_ops!.dirConflicts); + _ops!.dirConflicts.clear(); if (move && !cancelled) await _deleteSettled(); made.add(dest); } else { diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index c319e2e..aecae60 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -381,6 +381,10 @@ class FileOps { /// 건너뛴 항목 (원본 경로) final skipped = []; + /// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것. + /// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음) + final dirConflicts = []; + FileOps({ this.bandwidthKBps = 0, this.onFileDone, @@ -950,7 +954,15 @@ class FileOps { } } if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue; - if (t != null && t.isDir) await vDelete(target); + // M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만). + // 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다 + if (t != null && t.isDir) { + if (!delete) { + dirConflicts.add(target); + continue; + } + await vDelete(target); + } await _copyFileV(e.path, target, e.size, e.modified, null); n++; onSettled?.call(e.path, e.size, e.modified); diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index e2ac112..40dd972 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -25,6 +25,9 @@ enum PreviewAction { /// 231: 원본의 링크 · junction - 앱이 직접 맞출 때는 따라가지 않고 건너뛴다 (214) skipLink, + + /// M1 (10/19): 원본은 파일인데 받는 쪽에 같은 이름의 폴더가 있어 건너뜀 (--delete 가 없을 때 - 폴더를 지우지 않는다) + skipDir, } class PreviewItem { @@ -36,7 +39,12 @@ class PreviewItem { final bool toRight; final bool isDir; final int size; - const PreviewItem(this.rel, this.action, {required this.toRight, this.isDir = false, this.size = 0, this.newer = false}); + const PreviewItem(this.rel, this.action, + {required this.toRight, this.isDir = false, this.size = 0, this.newer = false, this.replacedByFile = false, this.inner = 0}); + + /// M1 (10/19): [PreviewAction.delete] 인 폴더가 원본의 같은 이름 파일로 바뀜 (--delete) - 그 폴더 안의 항목 수 [inner] + final bool replacedByFile; + final int inner; /// 229: [PreviewAction.update] 인데 받는 쪽이 더 새것 (예전 원본으로 새 파일을 덮어씀) final bool newer; @@ -133,6 +141,17 @@ Future> previewSync(String src, String dst, await walk(e.path, vJoin(d, name), r); continue; } + // M1 (관리자 P0, 10/19): 받는 쪽의 같은 이름이 폴더 - --delete 면 폴더 (안의 것 모두) 를 지우고 파일로, 아니면 건너뜀 (앱 맞추기 · rsync 같음) + if (t != null && t.isDir) { + if (delete) { + out.add(PreviewItem(r, PreviewAction.delete, + toRight: toRight, isDir: true, replacedByFile: true, inner: await _countInside(t.path))); + out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size)); + } else { + out.add(PreviewItem(r, PreviewAction.skipDir, toRight: toRight, size: e.size)); + } + continue; + } if (t == null) { out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size)); } else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) { @@ -175,3 +194,24 @@ Future> previewBoth(String left, String right, {bool delete = x, ]; } + +/// M1: 폴더 [dir] 안의 모든 항목 수 (지우고 파일로 바뀔 때 보이려고, [limit] 에서 멈춤) +Future _countInside(String dir, {int limit = 100000}) async { + var n = 0; + Future go(String d) async { + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(d); + } catch (_) { + return; + } + for (final x in items) { + if (n >= limit) return; + n++; + if (x.isDir) await go(x.path); + } + } + + await go(dir); + return n; +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 1657d93..6f129d0 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3306,6 +3306,8 @@ class _ExplorerPageState extends State { if (move && job.leftInSource > 0) trf('{0}개는 원본에 남음 (건너뜀)', [job.leftInSource]), if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것 {0}개 건너뜀', [job.keptNewer]), if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]), + // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 + if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 건너뜀 {0}개: {1}', [job.dirConflicts.length, _namesOf(job.dirConflicts)]), if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]), ].join(' · '); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index f31e1cc..9893eff 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -101,6 +101,7 @@ class _SyncPreviewViewState extends State { final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length; final skipped = n(PreviewAction.skipNewer); final links = n(PreviewAction.skipLink); + final dirSkips = n(PreviewAction.skipDir); final more = items.length >= widget.limit; final parts = [ for (final right in [true, false]) @@ -109,6 +110,7 @@ class _SyncPreviewViewState extends State { if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]), if (skipped > 0) trf('받는 쪽이 더 새것 {0} (건너뜀)', [skipped]), if (links > 0) trf('링크 {0} (건너뜀)', [links]), + if (dirSkips > 0) trf('같은 이름의 폴더가 있어 건너뜀 {0}', [dirSkips]), ]; return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row(children: [ @@ -153,14 +155,17 @@ class _SyncPreviewViewState extends State { PreviewAction.skipNewer => ' · ${x.rel} ${tr('(받는 쪽이 더 새것 - 건너뜀)')}', PreviewAction.skipLink => ' · ${x.rel}${x.isDir ? '/' : ''} ${tr('(링크 · junction - 따라가지 않고 건너뜀)')}', PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}', - PreviewAction.delete => '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}', + PreviewAction.delete => x.replacedByFile + ? '${_arrow(x)} − ${x.rel}/ ${trf('(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)', [x.inner])}' + : '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}', + PreviewAction.skipDir => ' · ${x.rel} ${tr('(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)')}', }, style: TextStyle( fontSize: 11.5, fontFamily: 'monospace', color: switch (x.action) { PreviewAction.delete => Colors.redAccent, - PreviewAction.update => Colors.orangeAccent, + PreviewAction.update || PreviewAction.skipDir => Colors.orangeAccent, PreviewAction.onlyTarget || PreviewAction.skipNewer || PreviewAction.skipLink => JjColors.textDim, _ => null, }, diff --git a/test/sftp_test.dart b/test/sftp_test.dart index dfe604a..24b9b23 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -1,248 +1,271 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/settings.dart'; -import 'package:jj_mkvmaker/core/sftp.dart'; -import 'package:jj_mkvmaker/core/vfs.dart'; -import 'package:jj_mkvmaker/services/secret_store.dart'; -import 'package:path/path.dart' as p; - -import 'support/sftp_server.dart'; - -/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - -/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. -void main() { - final hasServer = sftpTestServerAvailable; - final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen'; - - group('설정 · 경로 (서버 없이)', () { - test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async { - final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_'); - addTearDown(() => tmp.deleteSync(recursive: true)); - final secrets = MemorySecretStore(); - final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); - final s = AppSettings() - ..sftpServers = [ - const SftpServer( - id: 's1', - name: 'NAS', - host: 'nas.local', - port: 2222, - user: 'jj', - password: 'pw-secret', - privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----', - keyPassphrase: 'kp-secret', - legacyRsa: true, - hostKeyType: 'ssh-ed25519', - hostKeyFingerprint: 'SHA256:abc'), - ]; - await store.save(s); - final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync(); - expect(text, isNot(contains('pw-secret'))); - expect(text, isNot(contains('kp-secret'))); - expect(text, isNot(contains('OPENSSH PRIVATE KEY'))); - expect(secrets.values['sftp.pw.s1'], 'pw-secret'); - expect(secrets.values['sftp.kp.s1'], 'kp-secret'); - expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); - final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); - final x = back.sftpServers.single; - expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc')); - expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret')); - expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); - // .bak · 모르는 항목 보존용 JSON 에서도 빠진다 - final stripped = AppSettings.stripSecrets({ - 'sftpServers': [ - {'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'} - ] - }); - expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"')))); - expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}'); - }); - - test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () { - SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]); - addTearDown(() => SftpRegistry.configure(const [])); - final d = DavPath.parse(r'sftp://s1/a\b/'); - expect(d.full, 'sftp://s1/a/b'); - expect(d.isSftp, isTrue); - expect(d.parent.full, 'sftp://s1/a'); - expect(d.parent.parent.full, 'sftp://s1/'); - expect(d.child('c').full, 'sftp://s1/a/b/c'); - expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x'); - expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a'); - expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)'); - expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true)); - expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b'); - expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다'); - expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse); - }); - }); - - group('실제 SFTP 서버 (paramiko)', () { - late Directory tmp; - late Directory root; - SftpTestServer? server; - late int port; - late String fingerprint; - - Future start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async { - final s = await SftpTestServer.start( - root: root.path, - hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'), - password: password, - pubkey: pubkey, - keytype: keytype, - legacyOnly: legacyOnly); - server = s; - port = s.port; - fingerprint = s.fingerprint; - } - - setUp(() { - tmp = Directory.systemTemp.createTempSync('jj_sftp88_'); - root = Directory(p.join(tmp.path, 'root'))..createSync(); - }); - tearDown(() async { - SftpRegistry.configure(const []); - final s = server; - server = null; - await s?.stop(); - for (var i = 0;; i++) { - try { - if (tmp.existsSync()) tmp.deleteSync(recursive: true); - break; - } on FileSystemException { - if (i > 30) rethrow; - await Future.delayed(const Duration(milliseconds: 100)); - } - } - }); - - SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) => - SftpServer( - id: 's1', - name: 'test', - host: '127.0.0.1', - port: port, - user: 'jj', - password: password, - privateKey: key, - keyPassphrase: kp, - legacyRsa: legacy, - hostKeyFingerprint: fp, - root: rootDir); - - Future err(Future Function() f) async { - try { - await f(); - return null; - } catch (e) { - return e; - } - } - - test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async { - await start(); - final e = await err(() => SftpRemote(srv()).list('/')); - expect(e, isA()); - final hk = e! as SftpHostKeyException; - expect(hk.kind, SftpFailure.hostKeyUnknown); - expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문'); - expect(hk.type, 'ssh-ed25519'); - expect(hk.changed, isFalse); - // 믿음 → 연결 - final ok = SftpRemote(srv(fp: fingerprint)); - addTearDown(ok.close); - expect(await ok.list('/'), isEmpty); - // 바뀐 키 → 막고 예전 · 지금 지문을 알림 - final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/')); - expect(e2, isA()); - expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged); - expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키'); - expect('$e2', contains('바뀌었습니다')); - }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); - - test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async { - await start(); - final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/')); - expect((bad! as SftpException).kind, SftpFailure.auth); - final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/')); - expect((none! as SftpException).kind, SftpFailure.noSecret); - expect('$none', '저장된 비밀번호가 없습니다'); - // 닫힌 포트 - final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - final closedPort = free.port; - await free.close(); - final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'), - timeout: const Duration(seconds: 3)) - .list('/')); - expect((off! as SftpException).kind, SftpFailure.unreachable); - }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); - - test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async { - await start(); - SftpRegistry.configure([srv(fp: fingerprint)]); - const base = 'sftp://s1/'; - await vMkdirs('${base}a/b'); - expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue); - Stream> bytes(String s) => Stream.value(utf8.encode(s)); - await vWrite('${base}a/b/f.txt', bytes('hello')); - await vWrite('${base}a/b/f.txt', bytes('hello again')); - expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)'); - expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음'); - final list = await vList('${base}a/b'); - expect(list.single.path, 'sftp://s1/a/b/f.txt'); - expect((list.single.isDir, list.single.size), (false, 11)); - final st = await vStat('${base}a'); - expect(st!.isDir, isTrue); - expect(await vStat('$base없음'), isNull); - expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again'); - await vRename('${base}a/b/f.txt', '${base}a/g.txt'); - expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue); - // 받아서 열기 (임시 폴더) - final local = await vLocalCopy('${base}a/g.txt', tmp.path); - expect(File(local).readAsStringSync(), 'hello again'); - expect(local, contains('jj_sftp')); - await vDelete('${base}a'); - expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse); - // 시작 폴더: 그 안이 탭의 맨 위 - Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true); - File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m'); - SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]); - expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']); - final (free, _) = await DavPath.parse(base).client.quota(); - expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null'); - }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); - - test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async { - final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}'); - final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]); - if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음'); - addTearDown(() { - for (final f in [key, '$key.pub']) { - if (File(f).existsSync()) File(f).deleteSync(); - } - }); - await start(password: null, pubkey: '$key.pub'); - final pem = File(key).readAsStringSync(); - final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint)); - addTearDown(c.close); - expect(await c.list('/'), isEmpty); - final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); - expect((bad! as SftpException).kind, SftpFailure.auth); - expect('$bad', contains('개인 키의 암호')); - }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); - - test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { - await start(keytype: 'rsa', legacyOnly: true); - final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/')); - expect(e, isA()); - expect('$e', contains('ssh-rsa')); - final c = SftpRemote(srv(fp: fingerprint, legacy: true)); - addTearDown(c.close); - expect(await c.list('/'), isEmpty); - }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); - }); -} +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/sftp.dart'; +import 'package:jj_mkvmaker/core/vfs.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; +import 'package:path/path.dart' as p; + +import 'support/sftp_server.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/sync_preview.dart'; + +/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - +/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. +void main() { + final hasServer = sftpTestServerAvailable; + final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen'; + + group('설정 · 경로 (서버 없이)', () { + test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async { + final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final secrets = MemorySecretStore(); + final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + final s = AppSettings() + ..sftpServers = [ + const SftpServer( + id: 's1', + name: 'NAS', + host: 'nas.local', + port: 2222, + user: 'jj', + password: 'pw-secret', + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----', + keyPassphrase: 'kp-secret', + legacyRsa: true, + hostKeyType: 'ssh-ed25519', + hostKeyFingerprint: 'SHA256:abc'), + ]; + await store.save(s); + final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync(); + expect(text, isNot(contains('pw-secret'))); + expect(text, isNot(contains('kp-secret'))); + expect(text, isNot(contains('OPENSSH PRIVATE KEY'))); + expect(secrets.values['sftp.pw.s1'], 'pw-secret'); + expect(secrets.values['sftp.kp.s1'], 'kp-secret'); + expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); + final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); + final x = back.sftpServers.single; + expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc')); + expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret')); + expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); + // .bak · 모르는 항목 보존용 JSON 에서도 빠진다 + final stripped = AppSettings.stripSecrets({ + 'sftpServers': [ + {'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'} + ] + }); + expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"')))); + expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}'); + }); + + test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () { + SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]); + addTearDown(() => SftpRegistry.configure(const [])); + final d = DavPath.parse(r'sftp://s1/a\b/'); + expect(d.full, 'sftp://s1/a/b'); + expect(d.isSftp, isTrue); + expect(d.parent.full, 'sftp://s1/a'); + expect(d.parent.parent.full, 'sftp://s1/'); + expect(d.child('c').full, 'sftp://s1/a/b/c'); + expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x'); + expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a'); + expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)'); + expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true)); + expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b'); + expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다'); + expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse); + }); + }); + + group('실제 SFTP 서버 (paramiko)', () { + late Directory tmp; + late Directory root; + SftpTestServer? server; + late int port; + late String fingerprint; + + Future start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async { + final s = await SftpTestServer.start( + root: root.path, + hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'), + password: password, + pubkey: pubkey, + keytype: keytype, + legacyOnly: legacyOnly); + server = s; + port = s.port; + fingerprint = s.fingerprint; + } + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_sftp88_'); + root = Directory(p.join(tmp.path, 'root'))..createSync(); + }); + tearDown(() async { + SftpRegistry.configure(const []); + final s = server; + server = null; + await s?.stop(); + for (var i = 0;; i++) { + try { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + break; + } on FileSystemException { + if (i > 30) rethrow; + await Future.delayed(const Duration(milliseconds: 100)); + } + } + }); + + SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) => + SftpServer( + id: 's1', + name: 'test', + host: '127.0.0.1', + port: port, + user: 'jj', + password: password, + privateKey: key, + keyPassphrase: kp, + legacyRsa: legacy, + hostKeyFingerprint: fp, + root: rootDir); + + Future err(Future Function() f) async { + try { + await f(); + return null; + } catch (e) { + return e; + } + } + + test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async { + await start(); + final e = await err(() => SftpRemote(srv()).list('/')); + expect(e, isA()); + final hk = e! as SftpHostKeyException; + expect(hk.kind, SftpFailure.hostKeyUnknown); + expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문'); + expect(hk.type, 'ssh-ed25519'); + expect(hk.changed, isFalse); + // 믿음 → 연결 + final ok = SftpRemote(srv(fp: fingerprint)); + addTearDown(ok.close); + expect(await ok.list('/'), isEmpty); + // 바뀐 키 → 막고 예전 · 지금 지문을 알림 + final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/')); + expect(e2, isA()); + expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged); + expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키'); + expect('$e2', contains('바뀌었습니다')); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async { + await start(); + final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/')); + expect((bad! as SftpException).kind, SftpFailure.auth); + final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/')); + expect((none! as SftpException).kind, SftpFailure.noSecret); + expect('$none', '저장된 비밀번호가 없습니다'); + // 닫힌 포트 + final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final closedPort = free.port; + await free.close(); + final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'), + timeout: const Duration(seconds: 3)) + .list('/')); + expect((off! as SftpException).kind, SftpFailure.unreachable); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async { + await start(); + SftpRegistry.configure([srv(fp: fingerprint)]); + final local = Directory(p.join(tmp.path, 'local', 'src'))..createSync(recursive: true); + File(p.join(local.path, 'x')).writeAsStringSync('file'); + Directory(p.join(root.path, 'dst', 'x', 'sub')).createSync(recursive: true); + File(p.join(root.path, 'dst', 'x', 'keep.txt')).writeAsStringSync('k'); + const dst = 'sftp://s1/dst'; + var pv = await previewSync(local.path, dst, toRight: true); + expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']); + final ops = FileOps(); + await ops.mirror(local.path, dst); + expect(File(p.join(root.path, 'dst', 'x', 'keep.txt')).existsSync(), isTrue); + expect(ops.dirConflicts, hasLength(1)); + pv = await previewSync(local.path, dst, toRight: true, delete: true); + final del = pv.singleWhere((x) => x.action == PreviewAction.delete); + expect((del.replacedByFile, del.inner), (true, 2)); + await FileOps().mirror(local.path, dst, delete: true); + expect(File(p.join(root.path, 'dst', 'x')).readAsStringSync(), 'file'); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async { + await start(); + SftpRegistry.configure([srv(fp: fingerprint)]); + const base = 'sftp://s1/'; + await vMkdirs('${base}a/b'); + expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue); + Stream> bytes(String s) => Stream.value(utf8.encode(s)); + await vWrite('${base}a/b/f.txt', bytes('hello')); + await vWrite('${base}a/b/f.txt', bytes('hello again')); + expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)'); + expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음'); + final list = await vList('${base}a/b'); + expect(list.single.path, 'sftp://s1/a/b/f.txt'); + expect((list.single.isDir, list.single.size), (false, 11)); + final st = await vStat('${base}a'); + expect(st!.isDir, isTrue); + expect(await vStat('$base없음'), isNull); + expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again'); + await vRename('${base}a/b/f.txt', '${base}a/g.txt'); + expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue); + // 받아서 열기 (임시 폴더) + final local = await vLocalCopy('${base}a/g.txt', tmp.path); + expect(File(local).readAsStringSync(), 'hello again'); + expect(local, contains('jj_sftp')); + await vDelete('${base}a'); + expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse); + // 시작 폴더: 그 안이 탭의 맨 위 + Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true); + File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m'); + SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]); + expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']); + final (free, _) = await DavPath.parse(base).client.quota(); + expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null'); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async { + final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}'); + final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]); + if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음'); + addTearDown(() { + for (final f in [key, '$key.pub']) { + if (File(f).existsSync()) File(f).deleteSync(); + } + }); + await start(password: null, pubkey: '$key.pub'); + final pem = File(key).readAsStringSync(); + final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint)); + addTearDown(c.close); + expect(await c.list('/'), isEmpty); + final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); + expect((bad! as SftpException).kind, SftpFailure.auth); + expect('$bad', contains('개인 키의 암호')); + }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); + + test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { + await start(keytype: 'rsa', legacyOnly: true); + final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/')); + expect(e, isA()); + expect('$e', contains('ssh-rsa')); + final c = SftpRemote(srv(fp: fingerprint, legacy: true)); + addTearDown(c.close); + expect(await c.list('/'), isEmpty); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + }); +} diff --git a/test/webdav_test.dart b/test/webdav_test.dart index 5129482..fb35f13 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -1,193 +1,220 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/app_controller.dart'; -import 'package:jj_mkvmaker/app/live_sync.dart'; -import 'package:jj_mkvmaker/app/settings.dart'; -import 'package:jj_mkvmaker/core/file_ops.dart'; -import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; -import 'package:jj_mkvmaker/core/vfs.dart'; -import 'package:jj_mkvmaker/core/webdav.dart'; -import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; -import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; -import 'package:jj_mkvmaker/services/platform_services.dart'; -import 'package:path/path.dart' as p; - -import 'support/dav_server.dart'; - -void main() { - late Directory tmp, remote, local; - late TestDavServer server; - const id = 'nas'; - - setUp(() async { - tmp = Directory.systemTemp.createTempSync('jj_dav_'); - remote = Directory(p.join(tmp.path, 'remote'))..createSync(); - local = Directory(p.join(tmp.path, 'local'))..createSync(); - server = TestDavServer(remote); - await server.start(); - DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]); - }); - tearDown(() async { - DavRegistry.configure([]); - await server.stop(); - tmp.deleteSync(recursive: true); - }); - - File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel)) - ..parent.createSync(recursive: true) - ..writeAsStringSync(text); - File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel)) - ..parent.createSync(recursive: true) - ..writeAsStringSync(text); - - test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () { - expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b'); - expect(DavPath.parse('dav://nas').full, 'dav://nas/'); - expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더'); - expect(vDirname('dav://nas/a/b'), 'dav://nas/a'); - expect(vDirname('dav://nas/a'), 'dav://nas/'); - expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv'); - expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue); - expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue); - expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse); - expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse); - expect(samePath('dav://nas/a', r'C:\a'), isFalse); - }); - - test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async { - rf('영상/a.mkv', 'aaaa'); - rf('영상/sub/b.txt', 'b'); - final top = await listEntries('dav://$id/'); - expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]); - final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name); - expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]); - final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더'); - expect(made, 'dav://$id/영상/새 폴더'); - expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue); - final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv'); - expect(renamed, 'dav://$id/영상/c.mkv'); - expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa'); - await FileOps().delete(['dav://$id/영상/sub']); - expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse); - // 잘못된 비밀번호 - DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]); - expect(() => listEntries('dav://$id/'), throwsA(isA().having((e) => e.status, 'status', 401))); - }); - - test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async { - lf('up/x.txt', 'xx'); - lf('up/deep/y.bin', 'yyy'); - var bytes = 0; - final ops = FileOps(onBytes: (n) => bytes += n); - expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']); - expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy'); - expect(bytes, 5); - // 같은 이름이면 "이름 (2)" - expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']); - // WebDAV → 로컬 - final down = Directory(p.join(local.path, 'down'))..createSync(); - await ops.copy(['dav://$id/up'], down.path); - expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx'); - // 같은 서버 안 이동 (서버가 MOVE) - await FileOps.makeFolder('dav://$id/', 'box'); - await ops.move(['dav://$id/up (2)'], 'dav://$id/box'); - expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue); - expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse); - expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty); - // 로컬 → WebDAV 이동 (복사 후 원본 지움) - lf('mv/m.txt', 'm'); - await ops.move([p.join(local.path, 'mv')], 'dav://$id/box'); - expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm'); - expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse); - }); - - test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async { - lf('src/a.txt', 'a'); - lf('src/sub/b.txt', 'b'); - final ops = FileOps(); - expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2); - expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b'); - // 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것) - expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0); - // 원본에서 내용 (크기) 이 바뀐 것만 - lf('src/a.txt', 'aaa'); - expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1); - expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa'); - // 지우기 포함: 대상에만 있는 것 - rf('dst/extra.txt', 'e'); - await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true); - expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse); - // -u: 대상이 더 새것이면 덮어쓰지 않음 - rf('dst/a.txt', 'remote newer!!'); - final old = DateTime.now().subtract(const Duration(days: 1)); - File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old); - await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true); - expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!'); - // 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음) - final back = p.join(local.path, 'back'); - expect(await ops.mirror('dav://$id/dst', back), 2); - expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b'); - expect(await ops.mirror('dav://$id/dst', back), 0); - }); - - test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async { - rf('p/e1/e2/.keep', ''); - File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync(); - rf('p/f/z.txt', 'z'); - expect(await vCountFiles('dav://$id/p'), 1); - expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1 - expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue); - expect(await DavRegistry.client(id).quota(), (1000000, 500)); - final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache')); - expect(File(got).readAsStringSync(), 'z'); - expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로')); - }); - - test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async { - lf('live/a.txt', 'a'); - lf('live/sub/b.txt', 'bb'); - rf('mirror/old.txt', 'o'); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; - final live = LiveSync(c); - expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt'])); - await live.syncNow(pair); - expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); - expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb'); - expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse); - expect(live.pending[LiveSync.keyOf(pair)], isEmpty); - live.dispose(); - }); - - test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async { - rf('영상/1화.mkv', 'v1'); - rf('영상/2화.mkv', 'v2'); - rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); - rf('영상/메모.txt', 't'); - final s = davStream('dav://$id/영상/1화.mkv'); - expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}'); - expect(s.headers['Authorization'], startsWith('Basic ')); - // 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다 - expect(vPlayable('dav://$id/영상/1화.mkv'), s.url); - expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass'))); - expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv'); - // 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청) - final http = HttpClient(); - final req = await http.getUrl(Uri.parse(s.url)); - s.headers.forEach(req.headers.set); - final res = await req.close(); - expect(await res.transform(const SystemEncoding().decoder).join(), 'v1'); - http.close(); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.playlistMode = PlaylistMode.folder; - final before = server.requests.where((r) => r.startsWith('GET')).length; - final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']); - expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']); - expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음 - final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv'); - expect(subs.map(p.basename), ['1화.ko.srt']); - expect(File(subs.single).readAsStringSync(), contains('안녕')); - }); -} +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; +import 'package:jj_mkvmaker/core/vfs.dart'; +import 'package:jj_mkvmaker/core/webdav.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +import 'support/dav_server.dart'; +import 'package:jj_mkvmaker/core/sync_preview.dart'; + +void main() { + late Directory tmp, remote, local; + late TestDavServer server; + const id = 'nas'; + + setUp(() async { + tmp = Directory.systemTemp.createTempSync('jj_dav_'); + remote = Directory(p.join(tmp.path, 'remote'))..createSync(); + local = Directory(p.join(tmp.path, 'local'))..createSync(); + server = TestDavServer(remote); + await server.start(); + DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]); + }); + tearDown(() async { + DavRegistry.configure([]); + await server.stop(); + tmp.deleteSync(recursive: true); + }); + + File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel)) + ..parent.createSync(recursive: true) + ..writeAsStringSync(text); + File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel)) + ..parent.createSync(recursive: true) + ..writeAsStringSync(text); + + test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () { + expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b'); + expect(DavPath.parse('dav://nas').full, 'dav://nas/'); + expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더'); + expect(vDirname('dav://nas/a/b'), 'dav://nas/a'); + expect(vDirname('dav://nas/a'), 'dav://nas/'); + expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv'); + expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue); + expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue); + expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse); + expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse); + expect(samePath('dav://nas/a', r'C:\a'), isFalse); + }); + + test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async { + rf('영상/a.mkv', 'aaaa'); + rf('영상/sub/b.txt', 'b'); + final top = await listEntries('dav://$id/'); + expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]); + final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name); + expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]); + final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더'); + expect(made, 'dav://$id/영상/새 폴더'); + expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue); + final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv'); + expect(renamed, 'dav://$id/영상/c.mkv'); + expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa'); + await FileOps().delete(['dav://$id/영상/sub']); + expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse); + // 잘못된 비밀번호 + DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]); + expect(() => listEntries('dav://$id/'), throwsA(isA().having((e) => e.status, 'status', 401))); + }); + + test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async { + lf('up/x.txt', 'xx'); + lf('up/deep/y.bin', 'yyy'); + var bytes = 0; + final ops = FileOps(onBytes: (n) => bytes += n); + expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']); + expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy'); + expect(bytes, 5); + // 같은 이름이면 "이름 (2)" + expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']); + // WebDAV → 로컬 + final down = Directory(p.join(local.path, 'down'))..createSync(); + await ops.copy(['dav://$id/up'], down.path); + expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx'); + // 같은 서버 안 이동 (서버가 MOVE) + await FileOps.makeFolder('dav://$id/', 'box'); + await ops.move(['dav://$id/up (2)'], 'dav://$id/box'); + expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue); + expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse); + expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty); + // 로컬 → WebDAV 이동 (복사 후 원본 지움) + lf('mv/m.txt', 'm'); + await ops.move([p.join(local.path, 'mv')], 'dav://$id/box'); + expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm'); + expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse); + }); + + test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async { + lf('src/a.txt', 'a'); + lf('src/sub/b.txt', 'b'); + final ops = FileOps(); + expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2); + expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b'); + // 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것) + expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0); + // 원본에서 내용 (크기) 이 바뀐 것만 + lf('src/a.txt', 'aaa'); + expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1); + expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa'); + // 지우기 포함: 대상에만 있는 것 + rf('dst/extra.txt', 'e'); + await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true); + expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse); + // -u: 대상이 더 새것이면 덮어쓰지 않음 + rf('dst/a.txt', 'remote newer!!'); + final old = DateTime.now().subtract(const Duration(days: 1)); + File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old); + await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true); + expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!'); + // 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음) + final back = p.join(local.path, 'back'); + expect(await ops.mirror('dav://$id/dst', back), 2); + expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b'); + expect(await ops.mirror('dav://$id/dst', back), 0); + }); + + test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async { + rf('p/e1/e2/.keep', ''); + File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync(); + rf('p/f/z.txt', 'z'); + expect(await vCountFiles('dav://$id/p'), 1); + expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1 + expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue); + expect(await DavRegistry.client(id).quota(), (1000000, 500)); + final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache')); + expect(File(got).readAsStringSync(), 'z'); + expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로')); + }); + + test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async { + lf('live/a.txt', 'a'); + lf('live/sub/b.txt', 'bb'); + rf('mirror/old.txt', 'o'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); + c.settings.liveSyncPairs = [pair]; + final live = LiveSync(c); + expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt'])); + await live.syncNow(pair); + expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); + expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb'); + expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse); + expect(live.pending[LiveSync.keyOf(pair)], isEmpty); + live.dispose(); + }); + + test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async { + rf('영상/1화.mkv', 'v1'); + rf('영상/2화.mkv', 'v2'); + rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); + rf('영상/메모.txt', 't'); + final s = davStream('dav://$id/영상/1화.mkv'); + expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}'); + expect(s.headers['Authorization'], startsWith('Basic ')); + // 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다 + expect(vPlayable('dav://$id/영상/1화.mkv'), s.url); + expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass'))); + expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv'); + // 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청) + final http = HttpClient(); + final req = await http.getUrl(Uri.parse(s.url)); + s.headers.forEach(req.headers.set); + final res = await req.close(); + expect(await res.transform(const SystemEncoding().decoder).join(), 'v1'); + http.close(); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.playlistMode = PlaylistMode.folder; + final before = server.requests.where((r) => r.startsWith('GET')).length; + final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']); + expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']); + expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음 + final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv'); + expect(subs.map(p.basename), ['1화.ko.srt']); + expect(File(subs.single).readAsStringSync(), contains('안녕')); + }); + test('M1 (관리자 P0): 원본 파일 · 받는 쪽 같은 이름 폴더 - 지우기 없으면 폴더를 지우지 않고 건너뜀, 지우기면 지우고 파일로 · 미리 보기도 같음', () async { + lf('src/x', 'file'); + lf('src/ok.txt', 'ok'); + rf('dst/x/keep.txt', 'k'); + rf('dst/x/sub/deep.txt', 'd'); + final src = p.join(local.path, 'src'); + const dst = 'dav://$id/dst'; + // 지우기 없음: 미리 보기 "건너뜀" · 지울 것 없음 → 실제로도 폴더 그대로 + var pv = await previewSync(src, dst, toRight: true); + expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']); + expect(pv.where((x) => x.action == PreviewAction.delete), isEmpty); + final ops = FileOps(); + await ops.mirror(src, dst); + expect(File(p.join(remote.path, 'dst', 'x', 'keep.txt')).readAsStringSync(), 'k', reason: '예전에는 이 폴더를 통째로 지웠다'); + expect(File(p.join(remote.path, 'dst', 'x', 'sub', 'deep.txt')).existsSync(), isTrue); + expect(File(p.join(remote.path, 'dst', 'ok.txt')).readAsStringSync(), 'ok', reason: '다른 것은 그대로 맞춤'); + expect(ops.dirConflicts.map(vBasename), ['x']); + // 지우기 포함: 미리 보기에 "폴더 · 안의 3개를 지우고 파일로" → 실제로 폴더가 파일로 + pv = await previewSync(src, dst, toRight: true, delete: true); + final del = pv.singleWhere((x) => x.action == PreviewAction.delete); + expect((del.rel, del.isDir, del.replacedByFile, del.inner), ('x', true, true, 3)); + final ops2 = FileOps(); + await ops2.mirror(src, dst, delete: true); + expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file'); + expect(ops2.dirConflicts, isEmpty); + }); +}