Files
BadNote/test/webdav_sync_service_test.dart

428 lines
15 KiB
Dart
Raw Normal View History

// 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<String, Uint8List> files = {};
final Map<String, DateTime> mtimes = {};
final Set<String> collections = {};
final List<String> 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<RemoteEntry>> list(String remoteDir) async {
return files.keys
.map((path) => RemoteEntry(
path: path,
isDirectory: false,
modified: mtimes[path],
size: files[path]!.length,
))
.toList();
}
@override
Future<Uint8List> download(String remotePath) async {
final bytes = files[remotePath];
if (bytes == null) {
throw WebDavException('not found: $remotePath', statusCode: 404);
}
return bytes;
}
@override
Future<void> upload(String remotePath, Uint8List bytes) async {
files[remotePath] = bytes;
mtimes[remotePath] = DateTime.now().toUtc();
uploads.add(remotePath);
}
@override
Future<void> makeCollection(String remotePath) async {
collections.add(remotePath);
}
@override
Future<void> 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<WebDavSyncService> makeService() async {
final prefs = await SharedPreferences.getInstance();
return WebDavSyncService(prefs);
}
Future<File> 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<String> localConflictCopies(String rel) {
final folder = Directory(p.join(vaultDir.path, p.dirname(rel)));
final base = p.basename(rel);
return folder
.listSync()
.whereType<File>()
.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);
});
});
}