JJ_MKVMaker/test/smb_test.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);
});
}