// Proves the WebDAV sync ALGORITHM against a FAKE in-memory WebDavClient (no // real server — the HttpWebDavClient adapter is device/server-validated only). // // Covers: the pure per-file decision (decide), vault<->remote path mapping, and // the full syncNow orchestration — newer-local→upload, newer-remote→download, // both-changed→.conflict kept (no data lost), new-local→created remotely. import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; import 'package:badnote/services/webdav_client.dart'; import 'package:badnote/services/webdav_sync_service.dart'; /// In-memory fake WebDAV server. Stores bytes + mtimes by remote path /// (forward-slashed, relative to the remote root). Records uploads so tests can /// assert what was pushed. class FakeWebDavClient implements WebDavClient { final Map files = {}; final Map mtimes = {}; final Set collections = {}; final List uploads = []; bool testConnectionThrows = false; /// Seed a remote file with bytes + an mtime. void seed(String remotePath, String content, DateTime mtime) { files[remotePath] = Uint8List.fromList(utf8.encode(content)); mtimes[remotePath] = mtime.toUtc(); } @override Future> list(String remoteDir) async { return files.keys .map((path) => RemoteEntry( path: path, isDirectory: false, modified: mtimes[path], size: files[path]!.length, )) .toList(); } @override Future download(String remotePath) async { final bytes = files[remotePath]; if (bytes == null) { throw WebDavException('not found: $remotePath', statusCode: 404); } return bytes; } @override Future upload(String remotePath, Uint8List bytes) async { files[remotePath] = bytes; mtimes[remotePath] = DateTime.now().toUtc(); uploads.add(remotePath); } @override Future makeCollection(String remotePath) async { collections.add(remotePath); } @override Future testConnection() async { if (testConnectionThrows) throw WebDavException('boom'); } String? contentOf(String remotePath) { final b = files[remotePath]; return b == null ? null : utf8.decode(b); } } DateTime _utc(int y, int mo, int d, [int h = 0, int mi = 0, int s = 0]) => DateTime.utc(y, mo, d, h, mi, s); void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory vaultDir; setUp(() async { SharedPreferences.setMockInitialValues({}); vaultDir = await Directory.systemTemp.createTemp('webdav_sync_test'); }); tearDown(() async { if (await vaultDir.exists()) await vaultDir.delete(recursive: true); }); Future makeService() async { final prefs = await SharedPreferences.getInstance(); return WebDavSyncService(prefs); } Future writeLocal(String rel, String content, DateTime mtime) async { final f = File(p.join(vaultDir.path, rel)); await f.parent.create(recursive: true); await f.writeAsString(content); await f.setLastModified(mtime); return f; } String readLocal(String rel) => File(p.join(vaultDir.path, rel)).readAsStringSync(); List localConflictCopies(String rel) { final folder = Directory(p.join(vaultDir.path, p.dirname(rel))); final base = p.basename(rel); return folder .listSync() .whereType() .map((f) => p.basename(f.path)) .where((n) => n.startsWith('$base${WebDavSyncService.conflictMarker}')) .toList(); } group('decide (pure per-file decision)', () { SyncBaseline both(DateTime l, DateTime r) => SyncBaseline(localMtime: l, remoteMtime: r); test('local-only → upload', () { final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: _utc(2026, 1, 1), remoteMtime: null, baseline: null, )); expect(d.action, SyncAction.upload); }); test('remote-only never-seen → download', () { final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: null, remoteMtime: _utc(2026, 1, 1), baseline: null, )); expect(d.action, SyncAction.download); }); test('remote-only but known-before → download (restore, never delete)', () { final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: null, remoteMtime: _utc(2026, 1, 2), baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), )); expect(d.action, SyncAction.download); }); test('both unchanged since baseline → skip', () { final t = _utc(2026, 1, 1, 10); final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: t, remoteMtime: t, baseline: both(t, t), )); expect(d.action, SyncAction.skip); }); test('unchanged with DIFFERENT but-baselined mtimes per side → skip', () { // After an upload the server stamps its own mtime; comparing each side to // ITS baseline must still read as "no change". final localT = _utc(2026, 1, 1, 10); final remoteT = _utc(2026, 1, 1, 10, 0, 5); // server clock differs final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: localT, remoteMtime: remoteT, baseline: both(localT, remoteT), )); expect(d.action, SyncAction.skip); }); test('only local changed → upload', () { final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: _utc(2026, 1, 2), remoteMtime: _utc(2026, 1, 1), baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), )); expect(d.action, SyncAction.upload); }); test('only remote changed → download', () { final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: _utc(2026, 1, 1), remoteMtime: _utc(2026, 1, 2), baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), )); expect(d.action, SyncAction.download); }); test('both changed → conflict, newer wins (local newer)', () { final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: _utc(2026, 1, 3), remoteMtime: _utc(2026, 1, 2), baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), )); expect(d.action, SyncAction.conflict); expect(d.conflictWinnerIsLocal, isTrue); }); test('both changed → conflict, newer wins (remote newer)', () { final d = WebDavSyncService.decide(FileFacts( relPath: 'a/x.pdf', localMtime: _utc(2026, 1, 2), remoteMtime: _utc(2026, 1, 3), baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), )); expect(d.action, SyncAction.conflict); expect(d.conflictWinnerIsLocal, isFalse); }); }); group('path mapping', () { test('toRemotePath forward-slashes the relative path', () { final rel = p.join('Lecture', 'Lecture.pdf'); expect(WebDavSyncService.toRemotePath(rel), 'Lecture/Lecture.pdf'); }); test('toLocalRelPath round-trips with OS separators', () { const remote = 'Lecture/Lecture.pdf'; final local = WebDavSyncService.toLocalRelPath(remote); expect(local, p.join('Lecture', 'Lecture.pdf')); expect(WebDavSyncService.toRemotePath(local), remote); }); test('isSyncable filters tmp/bak/hidden', () { expect(WebDavSyncService.isSyncable('a/x.pdf'), isTrue); expect(WebDavSyncService.isSyncable('a/x.pdf.badnote.json'), isTrue); expect(WebDavSyncService.isSyncable('a/x.pdf.tmp'), isFalse); expect(WebDavSyncService.isSyncable('a/x.pdf.bak'), isFalse); expect(WebDavSyncService.isSyncable('.hidden'), isFalse); }); }); group('syncNow orchestration (fake client)', () { test('new-local → created remotely (upload + MKCOL)', () async { final sync = await makeService(); final fake = FakeWebDavClient(); await writeLocal('Lecture/Lecture.pdf', 'pdf-bytes', _utc(2026, 1, 1)); final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(r.ok, isTrue); expect(r.uploaded, 1); expect(r.downloaded, 0); expect(r.conflicts, 0); expect(fake.contentOf('Lecture/Lecture.pdf'), 'pdf-bytes'); // Parent collection was created before the upload. expect(fake.collections, contains('Lecture')); }); test('remote-only → downloaded into the vault', () async { final sync = await makeService(); final fake = FakeWebDavClient(); fake.seed('Deck/Deck.pptx', 'remote-deck', _utc(2026, 2, 1)); final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(r.downloaded, 1); expect(readLocal(p.join('Deck', 'Deck.pptx')), 'remote-deck'); }); test('newer-local → upload overwrites remote', () async { final sync = await makeService(); final fake = FakeWebDavClient(); // First sync establishes a shared baseline. await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(fake.contentOf('N/n.json'), 'v1'); // Local edited (newer mtime), remote untouched → upload. await writeLocal('N/n.json', 'v2-local', _utc(2026, 1, 5)); final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(r.uploaded, 1); expect(r.conflicts, 0); expect(fake.contentOf('N/n.json'), 'v2-local'); }); test('newer-remote → download overwrites local', () async { final sync = await makeService(); final fake = FakeWebDavClient(); await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); await sync.syncNow(vaultRoot: vaultDir.path, client: fake); // Remote edited (newer), local untouched → download. fake.seed('N/n.json', 'v2-remote', _utc(2026, 1, 6)); final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(r.downloaded, 1); expect(r.conflicts, 0); expect(readLocal(p.join('N', 'n.json')), 'v2-remote'); }); test('both-changed → .conflict kept, no data lost (local wins)', () async { final sync = await makeService(); final fake = FakeWebDavClient(); await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); await sync.syncNow(vaultRoot: vaultDir.path, client: fake); // BOTH sides change after the baseline; local is NEWER → local wins. await writeLocal('N/n.json', 'local-edit', _utc(2026, 1, 10)); fake.seed('N/n.json', 'remote-edit', _utc(2026, 1, 8)); final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(r.conflicts, 1); // Canonical is the winner (local) on BOTH sides. expect(readLocal(p.join('N', 'n.json')), 'local-edit'); expect(fake.contentOf('N/n.json'), 'local-edit'); // The LOSER (remote bytes) is preserved as a local .conflict-* copy … final copies = localConflictCopies(p.join('N', 'n.json')); expect(copies, hasLength(1)); expect(File(p.join(vaultDir.path, 'N', copies.single)).readAsStringSync(), 'remote-edit'); // … and pushed remotely too, so neither side loses the loser. final remoteConflict = fake.files.keys.firstWhere( (k) => k.contains(WebDavSyncService.conflictMarker), orElse: () => '', ); expect(remoteConflict, isNotEmpty); expect(fake.contentOf(remoteConflict), 'remote-edit'); }); test('both-changed → .conflict kept, no data lost (remote wins)', () async { final sync = await makeService(); final fake = FakeWebDavClient(); await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); await sync.syncNow(vaultRoot: vaultDir.path, client: fake); // BOTH change; remote is NEWER → remote wins. await writeLocal('N/n.json', 'local-edit', _utc(2026, 1, 8)); fake.seed('N/n.json', 'remote-edit', _utc(2026, 1, 12)); final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(r.conflicts, 1); // Canonical is the remote winner on both sides. expect(readLocal(p.join('N', 'n.json')), 'remote-edit'); expect(fake.contentOf('N/n.json'), 'remote-edit'); // The LOSER (local bytes) is preserved as a .conflict-* copy locally. final copies = localConflictCopies(p.join('N', 'n.json')); expect(copies, hasLength(1)); expect(File(p.join(vaultDir.path, 'N', copies.single)).readAsStringSync(), 'local-edit'); }); test('unchanged on both sides → skip, nothing re-uploaded', () async { final sync = await makeService(); final fake = FakeWebDavClient(); await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); await sync.syncNow(vaultRoot: vaultDir.path, client: fake); fake.uploads.clear(); final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(r.skipped, greaterThanOrEqualTo(1)); expect(r.uploaded, 0); expect(r.downloaded, 0); expect(fake.uploads, isEmpty); }); test('missing vault folder → friendly error, no throw', () async { final sync = await makeService(); final fake = FakeWebDavClient(); final r = await sync.syncNow( vaultRoot: p.join(vaultDir.path, 'does-not-exist'), client: fake, ); expect(r.ok, isFalse); expect(r.error, isNotNull); }); test('tmp/bak artifacts are not uploaded', () async { final sync = await makeService(); final fake = FakeWebDavClient(); await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); await writeLocal('N/n.json.tmp', 'partial', _utc(2026, 1, 1)); await writeLocal('N/n.json.bak', 'backup', _utc(2026, 1, 1)); await sync.syncNow(vaultRoot: vaultDir.path, client: fake); expect(fake.files.keys, contains('N/n.json')); expect(fake.files.keys.any((k) => k.endsWith('.tmp')), isFalse); expect(fake.files.keys.any((k) => k.endsWith('.bak')), isFalse); }); }); group('config persistence', () { test('saveConfig round-trips through SharedPreferences', () async { final sync = await makeService(); await sync.saveConfig(const WebDavConfig( baseUrl: 'https://dav.example.com', username: 'me', password: 'secret', remoteRoot: 'BadNote', autoSync: true, )); final c = sync.config; expect(c.baseUrl, 'https://dav.example.com'); expect(c.username, 'me'); expect(c.password, 'secret'); expect(c.remoteRoot, 'BadNote'); expect(c.autoSync, isTrue); expect(c.isConfigured, isTrue); }); test('buildClient returns null when unconfigured', () async { final sync = await makeService(); expect(sync.buildClient(), isNull); expect(sync.config.isConfigured, isFalse); }); }); }