JJ_MKVMaker/test/settings_store_test.dart

212 lines
12 KiB
Dart

import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/ftp.dart';
import 'package:jj_mkvmaker/core/sftp.dart';
import 'package:jj_mkvmaker/core/smb.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/services/image_ai.dart' show AiService;
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory dir;
late String file;
setUp(() {
dir = Directory.systemTemp.createTempSync('jj_settings_');
file = p.join(dir.path, 'settings.json');
});
tearDown(() => dir.deleteSync(recursive: true));
test('저장: 임시 파일에 쓴 뒤 바꿔치기 · 직전 정상본은 .bak · 남는 임시 파일 없음', () async {
final store = SettingsStore(file);
await store.save(AppSettings()..homeUrl = 'https://a.example');
await store.save(AppSettings()..homeUrl = 'https://b.example');
expect((await SettingsStore(file).load()).homeUrl, 'https://b.example');
expect(File('$file.bak').readAsStringSync(), contains('https://a.example'));
expect(File('$file.tmp').existsSync(), isFalse);
// 여러 번 빠르게 저장해도 마지막 것이 남는다 (순서대로)
await Future.wait([for (var i = 0; i < 5; i++) store.save(AppSettings()..homeUrl = 'https://$i.example')]);
expect((await SettingsStore(file).load()).homeUrl, 'https://4.example');
});
test('관리자 지시 (10/21): 보통 저장은 이 판이 만드는 꼴의 열쇠만 지운다 - 모르는 꼴 (새 판의 dav.token.x 등) 은 남김 · 지운 서버의 아는 열쇠만', () async {
final tmp = Directory.systemTemp.createTempSync('jj_secret_form_');
addTearDown(() => tmp.deleteSync(recursive: true));
final secrets = MemorySecretStore()
..values['dav.token.d1'] = 'new-kind' // 새 판이 같은 머리글로 쓴 새 종류
..values['sftp.cert.s1'] = 'new-kind-2'
..values['nfs.pw.n1'] = 'new-server-kind'
..values['sec.master'] = 'hash';
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = await store.load()
..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')]
..sftpServers = [
const SftpServer(id: 's1', name: 'a', host: 'h', user: 'u', password: 'sp1', privateKey: 'KEY1', keyPassphrase: 'kp1'),
const SftpServer(id: 's2', name: 'b', host: 'h', user: 'u', password: 'sp2'),
];
await store.save(s);
expect(
{for (final k in ['dav.pw.d1', 'sftp.pw.s1', 'sftp.key.s1', 'sftp.kp.s1', 'sftp.pw.s2']) k: secrets.values[k]},
{'dav.pw.d1': 'dp', 'sftp.pw.s1': 'sp1', 'sftp.key.s1': 'KEY1', 'sftp.kp.s1': 'kp1', 'sftp.pw.s2': 'sp2'});
// 서버 s1 을 지움: s1 의 아는 열쇠 (pw · key · kp) 만 없어지고, s2 · 모르는 꼴 · 마스터는 그대로
s.sftpServers = [s.sftpServers.last];
await store.save(s);
expect(secrets.values.keys.where((k) => k.endsWith('.s1')), ['sftp.cert.s1']);
expect(secrets.values['sftp.pw.s2'], 'sp2');
expect((secrets.values['dav.token.d1'], secrets.values['nfs.pw.n1'], secrets.values['sec.master']), ('new-kind', 'new-server-kind', 'hash'));
// 다시 읽어 저장해도 (새 SettingsStore = 앱을 다시 켬) 모르는 꼴은 남음
final again = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
await again.save(await again.load());
expect((secrets.values['dav.token.d1'], secrets.values['sftp.cert.s1']), ('new-kind', 'new-kind-2'));
// 초기화의 [저장된 실제 비밀번호 · API 키] 쪽 (clearForeignSecrets) 은 모르는 꼴도 지우고 sec. 만 남김
expect(await again.clearForeignSecrets(), 3);
expect(secrets.values.keys.where((k) => !SettingsStore.ownsSecret(k)), ['sec.master']);
});
test('관리자 규칙 (10/21): 이 판이 만드는 비밀 열쇠는 모두 secretForms 의 꼴 중 정확히 하나에만 맞고, 꼴마다 쓰인다 (새 종류는 겹치지 않는 새 머리글로)', () async {
final tmp = Directory.systemTemp.createTempSync('jj_secret_rule_');
addTearDown(() => tmp.deleteSync(recursive: true));
final secrets = MemorySecretStore()..values['sec.master'] = 'hash';
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = await store.load()
..openSubtitlesKey = 'k'
..openSubtitlesUser = 'u'
..openSubtitlesPassword = 'op'
..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')]
..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'KEY', keyPassphrase: 'kp')]
..smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')]
..ftpServers = [const FtpServer(id: 'f1', name: 'n', host: 'h', password: 'fp')]
..aiServices = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')];
await store.save(s);
final made = secrets.values.keys.where((k) => !k.startsWith('sec.')).toList()..sort();
expect(made, hasLength(11), reason: '$made'); // os 3 · dav.servers · dav.pw · sftp.pw · key · kp · smb.pw · ftp.pw · ai.key
final used = <RegExp>{};
for (final k in made) {
final hits = SettingsStore.secretForms.where((f) => f.hasMatch(k)).toList();
expect(hits, hasLength(1), reason: '$k 이 맞는 꼴: ${hits.map((f) => f.pattern)}');
used.addAll(hits);
}
expect(used, hasLength(SettingsStore.secretForms.length), reason: '안 쓰인 꼴: ${SettingsStore.secretForms.where((f) => !used.contains(f)).map((f) => f.pattern)}');
// 마스터 · 최상 (sec.) 은 설정의 꼴이 아니다 (설정 저장이 건드리지 않음)
expect(SettingsStore.ownsSecret('sec.master'), isFalse);
// 같은 머리글의 새 종류 (예: dav.token.x) 는 이 판의 꼴이 아니다 - 그래서 지우지 않지만, 새 종류는 새 머리글 (dav2.) 을 쓰는 것이 규칙
expect(SettingsStore.ownsSecret('dav.token.x'), isFalse);
expect(SettingsStore.ownsSecret('dav.pwx'), isFalse, reason: '꼴의 점 (.) 이 아무 글자가 되지 않게');
});
test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async {
final store = SettingsStore(file);
await store.save(AppSettings()..homeUrl = 'https://good.example');
await store.save(AppSettings()..homeUrl = 'https://good2.example'); // .bak = good
File(file).writeAsStringSync('{"homeUrl": "https://half'); // 반쯤 쓰다 끊긴 파일
final s = await store.load();
expect(s.homeUrl, 'https://good.example');
expect(store.problem!.restoredFromBackup, isTrue);
final kept = store.problem!.brokenCopy!;
expect(File(kept).readAsStringSync(), '{"homeUrl": "https://half');
// 다음 저장이 좋은 백업을 깨진 파일로 덮지 않는다
await store.save(s);
expect(File('$file.bak').readAsStringSync(), contains('good'));
});
test('깨졌고 백업도 없음: 처음 설정 + 알림, 깨진 파일 보관 / settings.json 만 없으면 백업으로', () async {
File(file).writeAsStringSync('not json');
final store = SettingsStore(file);
final s = await store.load();
expect(s.homeUrl, AppSettings().homeUrl);
expect(store.problem!.restoredFromBackup, isFalse);
expect(File(store.problem!.brokenCopy!).readAsStringSync(), 'not json');
// 바꿔치기 도중 끝나 settings.json 이 없고 .bak 만 있는 경우
File(file).deleteSync();
File('$file.bak').writeAsStringSync('{"homeUrl": "https://bak.example"}');
final again = SettingsStore(file);
expect((await again.load()).homeUrl, 'https://bak.example');
expect(again.problem!.restoredFromBackup, isTrue);
// 정상이면 알림 없음
await again.save(AppSettings());
await again.load();
expect(again.problem, isNull);
});
test('업데이트로 옮긴 기본값: 예전 파일에 그 값이 있을 때만 알림 목록에, 새로 저장한 파일은 다시 옮기지 않음', () {
final old = AppSettings.fromJson({
'screenOrientation': 'landscape',
'explorerLayout': 'dual',
'explorerClick': 'select',
'zipComic': true,
'imageExts': ['jpg'],
'runInBackground': false,
});
expect(old.screenOrientation, 'auto');
// 규칙 7 · 결정 3 (10/22): 처음 배치가 두 창이 되어 예전 'dual' 은 그대로 (옮겼다는 알림 없음)
expect(old.explorer.layout, 'dual');
expect(old.zipComic, isFalse);
expect(old.migrated, containsAll(['orientation', 'zipComic']));
expect(old.migrated, isNot(contains('explorerLayout')));
// 다시 저장 · 읽기: 이제 사용자가 고른 값으로 보고 그대로 · 알림 없음
final again = AppSettings.fromJson((old
..explorer.layout = 'dual'
..screenOrientation = 'landscape')
.toJson());
expect(again.explorer.layout, 'dual');
expect(again.screenOrientation, 'landscape');
expect(again.migrated, isEmpty);
// 처음 설치 (빈 설정) 는 알릴 것 없음
expect(AppSettings.fromJson({}).migrated, isEmpty);
});
test('E3 (10/22): 선택 규칙 이전 판의 처음 값은 한 번 규칙대로 옮기고 알림, 저장한 뒤에는 고른 값 그대로', () {
final old = AppSettings.fromJson({
'explorerV2': true,
'explorerLayout': 'auto',
'explorerAlwaysMarks': true,
'explorerRetapCollapse': false,
'explorerClick': 'open',
});
expect([old.explorer.layout, old.explorer.alwaysMarks, old.explorer.retapCollapse], ['dual', false, true]);
expect(old.explorer.click, Platform.isAndroid ? 'select' : 'open'); // Windows 의 'open' 은 사용자가 고른 값
expect(old.migrated, containsAll(['explorerLayoutRule', 'explorerMarksRule', 'explorerRetapRule']));
// 저장한 뒤 사용자가 다시 고른 값은 그대로 · 다시 묻지 않음
final again = AppSettings.fromJson((old
..explorer.layout = 'auto'
..explorer.alwaysMarks = true
..explorer.retapCollapse = false)
.toJson());
expect([again.explorer.layout, again.explorer.alwaysMarks, again.explorer.retapCollapse], ['auto', true, false]);
expect(again.migrated, isEmpty);
});
test('193: 예전 판에서 올라오면 바뀐 동작 (229 받는 쪽이 더 새것이면 건너뜀 · robocopy 링크) 을 한 번 알린다', () {
final old = AppSettings.fromJson({'copyMethodFolder': 'robocopy', 'explorerV2': true});
expect(old.migrated, contains('copySkipNewer'));
expect(old.migrated.contains('robocopyLinks'), Platform.isWindows, reason: 'robocopy 는 Windows 만');
// 저장 뒤 다시 읽으면 알리지 않음
final again = AppSettings.fromJson(old.toJson());
expect(again.migrated, isNot(contains('copySkipNewer')));
expect(again.migrated, isNot(contains('robocopyLinks')));
// robocopy 를 쓰지 않던 사람에게는 링크 알림 없음
expect(AppSettings.fromJson({'explorerV2': true}).migrated, isNot(contains('robocopyLinks')));
});
test('193: 그 설정이 없던 예전 판 (10.03 모양 - 키 없음) 에서 올라오면 바로 겪는 동작 변화를 알린다 · 처음 설치는 없음', () {
// 10.03 의 설정 파일: 탐색기 · 화면 이동 · 백그라운드 설정이 아직 없던 때 (있는 것은 그때의 항목들)
final old = AppSettings.fromJson({'theme': 'dark', 'outputFolderName': 'jj_mkv'});
expect(old.migrated, contains('swipeNav'));
// DNS 강제가 없던 판 (10.10 까지) 에서 올라오면 처음 값 직접 지정으로 바뀐 것을 알림 · 이미 고른 사람은 알리지 않음
expect(old.migrated, contains('dnsForced'));
expect(AppSettings.fromJson({'theme': 'dark', 'dnsMode': 'system'}).migrated, isNot(contains('dnsForced')));
if (Platform.isAndroid) {
expect(old.migrated, containsAll(['explorerClickNew', 'orientation', 'background']));
} else {
expect(old.migrated, isNot(contains('explorerClickNew')), reason: 'PC 는 처음 값이 한 번 누르면 고르기 (바뀐 것 없음)');
}
// 저장 뒤 다시 읽으면 알리지 않음
expect(AppSettings.fromJson(old.toJson()).migrated, isEmpty);
// 처음 설치 (설정 파일 없음) 는 알릴 것 없음
expect(AppSettings.fromJson({}).migrated, isEmpty);
});
}