From 22ae03953db585e4c224383e6368136661e51e8d Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:23:05 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EC=9B=90=EA=B2=A9=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=EC=B0=BD:=20=EC=98=B5=EC=85=98=EC=97=90=20=EA=B1=B0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20(--exclude=20=EB=93=B1)=20=EA=B0=80=20?= =?UTF-8?q?=EC=9E=88=EC=9C=BC=EB=A9=B4=20"=EC=9B=90=EA=B2=A9=EC=9D=80=20?= =?UTF-8?q?=EA=B1=B0=EB=A5=B4=EA=B8=B0=EB=A5=BC=20=EB=94=B0=EB=A5=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=8C"=20=EC=A3=BC=ED=99=A9=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=C2=B7=20=EC=95=88=EB=82=B4=20=EA=B8=80?= =?UTF-8?q?=EC=97=90=20SMB=20=C2=B7=20FTP=20(=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/19, 15 의 원격 동기화 검토 M2): 원격 (WebDAV · SFTP · SMB · FTP) 은 rsync 대신 앱이 직접 맞추며 옵션의 거르기 (--exclude · --include · --filter · -f · --exclude-from · --include-from · -C · -F) 를 따르지 않는다 - 지키려고 뺀 것도 복사하고, 지우기면 지운다. 미리 보기도 거르기 없이 계산해 실제와 같지만, 확인 창은 이것을 알리지 않았다. - 새 optionsFilter (sync_preview.dart): 옵션에 거르기가 있는지. - Rsync 화면 확인 창 · 지우기 포함 작업의 확인 창 (모니터링 [실행] 등, confirmDeletingRun): 원격이고 거르기가 있으면 주황 경고 "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 … 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다." - 원격 안내 글 "WebDAV · SFTP: rsync 대신 앱이 직접 맞춥니다" → "WebDAV · SFTP · SMB · FTP: …" (SMB · FTP 도 같은 길). - 관리자 지적: 6278403 에서 test/webdav_test.dart · test/sftp_test.dart 의 줄바꿈이 파일 전체에서 CRLF 로 바뀐 것을 원래 (LF) 로 되돌림 (6278403~1 과 비교하면 +27 · +23 줄만 남음). _mirrorV 가 RsyncFilter 를 따르게 하는 것은 next3 와 합친 뒤의 다음 일 (관리자 결정). 시험: sync_preview_test (새 1: optionsFilter 있음 10 · 없음 6, -e "ssh -C" 는 아님) · explorer_webdav_test (안내 글 · 거르기 없으면 경고 없음) · sync_preview_view · webdav · sftp 31 통과, 임시 폴더 흔적 0, analyze 문제 없음, l10n 빠짐 0. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 + assets/l10n/ja.json | 2 + assets/l10n/zh-Hans.json | 2 + lib/core/sync_preview.dart | 10 + lib/ui/explorer_page.dart | 9 +- lib/ui/sync_preview_view.dart | 7 + test/explorer_webdav_test.dart | 4 +- test/sftp_test.dart | 540 ++++++++++++++++----------------- test/sync_preview_test.dart | 19 ++ test/webdav_test.dart | 438 +++++++++++++------------- 10 files changed, 542 insertions(+), 491 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 4a6267d..a806a40 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -356,6 +356,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts", + "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV / SFTP reads everything inside from the server, which can take a long time. Analyze?", "WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV / SFTP can't be added to the MKV list. Copy them to this device first.", "WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV / SFTP files show only name, size and date", @@ -1892,6 +1893,7 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.", "우르두어": "Urdu", "우크라이나어": "Ukrainian", + "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "Remote locations (WebDAV · SFTP · SMB · FTP) do not follow the filter options (--exclude, --include, --filter, …) - excluded items are copied too, and deleted if the run deletes. The comparison list above is what will actually happen.", "원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}", "원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 64307fa..67b30a7 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -356,6 +356,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します", + "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?", "WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV・SFTP の動画は MKV リストに追加できません。先にこの端末へコピーしてください。", "WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV・SFTP のファイルは名前・サイズ・日付のみ表示します", @@ -1892,6 +1893,7 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。", "우르두어": "ウルドゥー語", "우크라이나어": "ウクライナ語", + "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "リモート (WebDAV・SFTP・SMB・FTP) はオプションのフィルター (--exclude・--include・--filter など) に従いません - 除外したものもコピーし、削除を含む場合は削除します。上の比較一覧が実際に行う内容です。", "원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}", "원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 10aedc3..1fe666d 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -356,6 +356,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}", "WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入", + "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?", "WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV · SFTP 上的视频加入 MKV 列表。请先复制到本设备。", "WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV · SFTP 文件仅显示名称、大小和日期", @@ -1892,6 +1893,7 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。", "우르두어": "乌尔都语", "우크라이나어": "乌克兰语", + "원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "远程 (WebDAV · SFTP · SMB · FTP) 不遵循选项中的过滤规则 (--exclude · --include · --filter 等) - 被排除的项目也会复制,如包含删除则会删除。上面的比较列表就是实际会执行的操作。", "원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}", "원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)", diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index 40dd972..8d4b4e8 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -57,6 +57,16 @@ bool isRsyncDeleteOption(String o) => o == '--del' || o.startsWith('--delete') | /// rsync 옵션에 지우기 (--delete 계열) 가 있는지 bool optionsDelete(String options) => splitOptions(options).any(isRsyncDeleteOption); +/// M2 (관리자 지시 10/19): rsync 옵션에 거르기 (--exclude · --include · --filter · -f · --exclude-from · --include-from · -C · -F) 가 있는지. +/// 원격 (WebDAV · SFTP · SMB · FTP) 은 앱이 직접 맞추며 이 거르기를 따르지 않으므로 확인 창에 알린다 +bool optionsFilter(String options) => splitOptions(options).any((o) => + o == '-f' || + o == '-F' || + o == '--cvs-exclude' || + o == '-C' || + RegExp(r'^--(exclude|include|filter|exclude-from|include-from)(=|$)').hasMatch(o) || + (RegExp(r'^-[a-zA-Z]+$').hasMatch(o) && (o.contains('C') || o.contains('F')))); + /// 옵션 글을 낱말로 나누되 따옴표는 그대로 둔 원래 글 조각 (106: --exclude="My Folder" 가 갈라지지 않게) List rawOptionTokens(String s) { final out = []; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 6f129d0..7c249bc 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3533,8 +3533,15 @@ class _ExplorerPageState extends State { ), const SizedBox(height: 4), if (_viaDav([l, r])) - Text(tr('WebDAV · SFTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).'), + Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).'), style: const TextStyle(fontSize: 12, color: JjColors.accent)), + // M2 (관리자 지시 10/19): 원격이고 옵션에 거르기가 있으면 - 앱 맞추기는 거르기를 따르지 않는다 (뺀 것도 복사 · 지움) + if (_viaDav([l, r]) && tasks.any((t) => optionsFilter(opts(t)))) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(tr('원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.'), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)), + ), ]), ), actions: [ diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index 9893eff..f4a1091 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -236,6 +236,13 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM ], // 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다) Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), + // M2 (관리자 지시 10/19): 원격 (앱이 직접 맞춤) 인데 옵션에 거르기가 있으면 - 거르기를 따르지 않는다 + if (direct && t.method == 'rsync' && optionsFilter(t.options)) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(tr('원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.'), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)), + ), // 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히 if (t.move) Padding( diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart index 99487ad..4ac56e7 100644 --- a/test/explorer_webdav_test.dart +++ b/test/explorer_webdav_test.dart @@ -205,7 +205,9 @@ void main() { // 72: 비교 - 보낼 것 · 받는 쪽 (WebDAV) 에만 있는 것 (지우기 없으니 그대로 둠) expect(find.textContaining('→ + inner.txt'), findsOneWidget); expect(find.textContaining('= note.txt'), findsOneWidget); - expect(find.textContaining('WebDAV · SFTP: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget); + expect(find.textContaining('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget); + // M2: 옵션에 거르기가 없으면 거르기 경고는 없다 + expect(find.textContaining('거르기 (--exclude'), findsNothing); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행'))); final got = File(p.join(remote.path, '원격', 'inner.txt')); await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200); diff --git a/test/sftp_test.dart b/test/sftp_test.dart index 24b9b23..213f62e 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -1,271 +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'; -import 'package:jj_mkvmaker/core/file_ops.dart'; +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) 없음'); - }); -} + +/// 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/sync_preview_test.dart b/test/sync_preview_test.dart index 084ef07..506550c 100644 --- a/test/sync_preview_test.dart +++ b/test/sync_preview_test.dart @@ -101,4 +101,23 @@ void main() { expect(r['only_b.txt'], (PreviewAction.delete, true)); expect(r['new.txt'], (PreviewAction.add, true)); }); + test('M2 (10/19): optionsFilter - 거르기 옵션이 있는지 (원격 확인 창의 경고)', () { + for (final o in [ + '-av --exclude=*.tmp', + '-av --exclude "My Folder"', + '-a --include=*.mkv --exclude=*', + '--filter="- *.bak"', + '-a -f "- x"', + '--exclude-from=list.txt', + '--include-from list.txt', + '-avC', + '--cvs-exclude', + '-aF', + ]) { + expect(optionsFilter(o), isTrue, reason: o); + } + for (final o in ['-avPog', '-a --delete', '-avu --remove-source-files', '--delete-excluded', '-e "ssh -C"', '']) { + expect(optionsFilter(o), isFalse, reason: o); + } + }); } diff --git a/test/webdav_test.dart b/test/webdav_test.dart index fb35f13..e857269 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -1,220 +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'; +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); - }); -} + +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); + }); +}