193 lines
8.4 KiB
Dart
193 lines
8.4 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:jj_mkvmaker/app/settings.dart';
|
|
import 'package:jj_mkvmaker/core/smb.dart';
|
|
import 'package:jj_mkvmaker/core/vfs.dart';
|
|
import 'package:jj_mkvmaker/services/secret_store.dart';
|
|
import 'package:jj_mkvmaker/ui/smb_settings.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
/// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫)
|
|
class _FakeSmb {
|
|
/// 공유 안 경로 ("a\\b") → 내용 (null 이면 폴더)
|
|
final files = <String, List<int>?>{};
|
|
final calls = <String>[];
|
|
String? failWith;
|
|
bool encrypted = true;
|
|
final _open = <int, (String, bool)>{};
|
|
var _next = 1;
|
|
|
|
String _key(Object? path) => '$path'.replaceAll(RegExp(r'^/+'), '').replaceAll('/', r'\');
|
|
|
|
Future<Object?> handle(MethodCall call) async {
|
|
calls.add(call.method);
|
|
final a = (call.arguments as Map?) ?? const {};
|
|
if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith');
|
|
switch (call.method) {
|
|
case 'info':
|
|
return {'encrypted': encrypted, 'dialect': 'SMB_3_1_1'};
|
|
case 'list':
|
|
final dir = _key(a['path']);
|
|
final prefix = dir.isEmpty ? '' : '$dir\\';
|
|
if (dir.isNotEmpty && !files.containsKey(dir)) throw PlatformException(code: 'notFound', message: dir);
|
|
return [
|
|
for (final e in files.entries)
|
|
if (e.key.startsWith(prefix) && e.key.length > prefix.length && !e.key.substring(prefix.length).contains(r'\'))
|
|
{'name': e.key.substring(prefix.length), 'dir': e.value == null, 'size': e.value?.length ?? 0, 'mtime': 1700000000000},
|
|
];
|
|
case 'stat':
|
|
final k = _key(a['path']);
|
|
if (k.isEmpty) return {'dir': true, 'size': 0, 'mtime': 0};
|
|
if (!files.containsKey(k)) return null;
|
|
return {'dir': files[k] == null, 'size': files[k]?.length ?? 0, 'mtime': 1700000000000};
|
|
case 'mkdir':
|
|
files[_key(a['path'])] = null;
|
|
return null;
|
|
case 'delete':
|
|
final k = _key(a['path']);
|
|
files.removeWhere((x, _) => x == k || x.startsWith('$k\\'));
|
|
return null;
|
|
case 'move':
|
|
final from = _key(a['path']), to = _key(a['to']);
|
|
if (files.containsKey(to) && a['overwrite'] != true) throw PlatformException(code: 'exists', message: to);
|
|
files[to] = files.remove(from);
|
|
return null;
|
|
case 'openRead':
|
|
final k = _key(a['path']);
|
|
if (!files.containsKey(k)) throw PlatformException(code: 'notFound', message: k);
|
|
final h = _next++;
|
|
_open[h] = (k, false);
|
|
return {'handle': h, 'size': files[k]!.length};
|
|
case 'readChunk':
|
|
final (k, _) = _open[a['handle']]!;
|
|
final data = files[k]!;
|
|
final off = a['offset'] as int;
|
|
if (off >= data.length) return Uint8List(0);
|
|
return Uint8List.fromList(data.sublist(off, (off + (a['length'] as int)).clamp(0, data.length)));
|
|
case 'openWrite':
|
|
final k = _key(a['path']);
|
|
files[k] = [];
|
|
final h = _next++;
|
|
_open[h] = (k, true);
|
|
return h;
|
|
case 'writeChunk':
|
|
final (k, _) = _open[a['handle']]!;
|
|
final off = a['offset'] as int;
|
|
final data = a['data'] as Uint8List;
|
|
final cur = files[k]!;
|
|
expect(off, cur.length, reason: '조각은 차례로');
|
|
files[k] = [...cur, ...data];
|
|
return null;
|
|
case 'closeHandle':
|
|
_open.remove(a['handle']);
|
|
return null;
|
|
case 'quota':
|
|
return [5 << 30, 1 << 30];
|
|
case 'close':
|
|
return null;
|
|
}
|
|
throw PlatformException(code: 'other', message: call.method);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
late _FakeSmb fake;
|
|
setUp(() {
|
|
fake = _FakeSmb();
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SmbRemote.channel, fake.handle);
|
|
});
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SmbRemote.channel, null);
|
|
SmbRegistry.configure(const []);
|
|
});
|
|
|
|
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'pw');
|
|
|
|
test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async {
|
|
SmbRegistry.configure([server]);
|
|
await vMkdirs('smb://m1/a/b');
|
|
expect(fake.files.keys, containsAll([r'a', r'a\b']));
|
|
final big = List<int>.generate((SmbRemote.chunk * 2.5).toInt(), (i) => i % 251);
|
|
await vWrite('smb://m1/a/b/big.bin', Stream.fromIterable([big.sublist(0, 1000), big.sublist(1000)]));
|
|
expect(fake.files[r'a\b\big.bin']!.length, big.length);
|
|
expect(fake.calls.where((c) => c == 'writeChunk').length, 3, reason: '1MB 조각');
|
|
expect(fake.files.keys.where((k) => k.endsWith('.jjpart')), isEmpty, reason: '.jjpart 로 쓴 뒤 이름 바꿈');
|
|
final back = await (await vOpenRead('smb://m1/a/b/big.bin')).expand((x) => x).toList();
|
|
expect(back, big);
|
|
final list = await vList('smb://m1/a/b');
|
|
expect(list.single.path, 'smb://m1/a/b/big.bin');
|
|
expect((list.single.isDir, list.single.size), (false, big.length));
|
|
await vWrite('smb://m1/a/x.txt', Stream.value(utf8.encode('x')));
|
|
await vRename('smb://m1/a/x.txt', 'smb://m1/a/y.txt');
|
|
expect(fake.files.containsKey(r'a\y.txt'), isTrue);
|
|
expect(await vStat('smb://m1/a/없음'), isNull);
|
|
await vDelete('smb://m1/a');
|
|
expect(fake.files, isEmpty);
|
|
expect(vDisplay('smb://m1/a/b'), '🗄 NAS/a/b');
|
|
expect(noStreamUrl('smb://m1/a.mkv'), isTrue, reason: '재생 · 다른 앱은 받아서');
|
|
final (free, used) = await DavPath.parse('smb://m1/').client.quota();
|
|
expect((free, used), (5 << 30, 1 << 30));
|
|
});
|
|
|
|
test('시작 폴더: 공유 안의 그 폴더가 탭의 맨 위', () async {
|
|
SmbRegistry.configure([server.copyWith(root: r'\movies\')]);
|
|
fake.files
|
|
..['movies'] = null
|
|
..[r'movies\m.mkv'] = [1, 2];
|
|
expect((await vList('smb://m1/')).map((e) => e.path), ['smb://m1/m.mkv']);
|
|
});
|
|
|
|
test('실패는 이유를 사람 말로 (SMB1 은 보안 문제로 연결하지 않음) · 저장된 비밀번호 없음은 묻지 않고 알림 · 암호화 여부', () async {
|
|
Future<String> err(SmbRemote r) async {
|
|
try {
|
|
await r.list('/');
|
|
return '';
|
|
} catch (e) {
|
|
return '$e';
|
|
}
|
|
}
|
|
|
|
for (final (code, want) in [
|
|
('auth', '아이디나 비밀번호가 맞지 않습니다'),
|
|
('unreachable', 'Tailscale'),
|
|
('noShare', '공유 폴더 "video"'),
|
|
('smb1', 'SMB1'),
|
|
('permission', '권한이 없습니다'),
|
|
]) {
|
|
fake.failWith = code;
|
|
expect(await err(SmbRemote(server)), contains(want), reason: code);
|
|
}
|
|
fake.failWith = null;
|
|
fake.calls.clear();
|
|
expect(await err(SmbRemote(server.copyWith(password: ''))), '저장된 비밀번호가 없습니다');
|
|
expect(fake.calls, isEmpty, reason: '비밀번호가 없으면 서버에 묻지 않는다');
|
|
// 게스트 (아이디 없음) 는 비밀번호 없이
|
|
expect(await err(SmbRemote(server.copyWith(user: '', password: ''))), '');
|
|
fake.encrypted = false;
|
|
expect((await SmbRemote(server).info()).encrypted, isFalse);
|
|
});
|
|
|
|
test('설정: 비밀번호는 안전 저장소 smb.pw.<id> 에만 · 다시 읽으면 채움', () async {
|
|
final tmp = Directory.systemTemp.createTempSync('jj_smb88_set_');
|
|
addTearDown(() => tmp.deleteSync(recursive: true));
|
|
final secrets = MemorySecretStore();
|
|
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
|
await store.save(AppSettings()..smbServers = [server]);
|
|
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('"pw"')));
|
|
expect(secrets.values['smb.pw.m1'], 'pw');
|
|
final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single;
|
|
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'pw'));
|
|
});
|
|
|
|
test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () {
|
|
expect(parseSmbAddress('smb://nas/video/a%20b/c'), ('nas', 'video', 'a b/c'));
|
|
expect(parseSmbAddress(r'\\nas\home'), ('nas', 'home', ''));
|
|
expect(parseSmbAddress('CIFS://x'), ('x', '', ''));
|
|
expect(parseSmbAddress('/storage/x'), isNull);
|
|
});
|
|
}
|