All checks were successful
CI / Windows build (push) Successful in 12m32s
Two-way sync of the vault folder to a user-configured WebDAV server, so annotations (which travel with the file) sync with the file. - WebDavSyncService.syncNow: per-file decision — local-only uploads, remote-only downloads, and when BOTH sides changed since the last sync it keeps the loser as <file>.conflict-<mtime> on both sides (last-write-wins by mtime) so no data is ever lost. Creates dirs as needed; deletes are conservative. - The decision logic is pure and unit-tested against a fake WebDAV client; the real client is a thin http adapter (no dio dependency). - Settings: WebDAV URL / user / password / remote folder, Test connection, Sync now (with status + last-synced), and an auto-sync toggle (default OFF). Real server round-trips are device/server-validated. Credentials are in SharedPreferences for now (TODO secure-storage). analyze clean, 432 tests.
428 lines
15 KiB
Dart
428 lines
15 KiB
Dart
// 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);
|
|
});
|
|
});
|
|
}
|