mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Fix gate: file caps, silent catch, pin bump
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
parent
7f9169b9d0
commit
5d3547c221
@ -167,28 +167,17 @@ systemctl --user daemon-reload
|
||||
# Enable the service to start on login (one-shot, no periodic timer)
|
||||
systemctl --user enable "$SERVICE_NAME"
|
||||
|
||||
# Enable the early bird re-check timer
|
||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
|
||||
# Timers: early bird re-check, periodic re-check, workout sync, the instant
|
||||
# bonus pass on ledger writes, and the daily RunnerUp retirement retry
|
||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME" "$LOCKER_TIMER_NAME" \
|
||||
"$SYNC_TIMER_NAME" "$BONUS_PATH_NAME" "$RETIRE_TIMER_NAME"
|
||||
|
||||
# Enable the periodic re-check timer
|
||||
systemctl --user enable --now "$LOCKER_TIMER_NAME"
|
||||
|
||||
# Enable the periodic workout sync
|
||||
systemctl --user enable --now "$SYNC_TIMER_NAME"
|
||||
|
||||
# Enable the instant bonus pass on ledger writes
|
||||
systemctl --user enable --now "$BONUS_PATH_NAME"
|
||||
|
||||
# Enable the instant RunnerUp credit on upload (restart picks up a new script)
|
||||
systemctl --user enable "$RUNNERUP_WATCH_NAME"
|
||||
systemctl --user restart "$RUNNERUP_WATCH_NAME"
|
||||
|
||||
# Enable the LAN workout poke listener (restart picks up new code)
|
||||
systemctl --user enable "$WORKOUT_POKE_NAME"
|
||||
systemctl --user restart "$WORKOUT_POKE_NAME"
|
||||
|
||||
# Enable the daily RunnerUp retirement retry
|
||||
systemctl --user enable --now "$RETIRE_TIMER_NAME"
|
||||
# Instant RunnerUp credit on upload, and the LAN workout poke listener
|
||||
# (restart picks up new code)
|
||||
for unit in "$RUNNERUP_WATCH_NAME" "$WORKOUT_POKE_NAME"; do
|
||||
systemctl --user enable "$unit"
|
||||
systemctl --user restart "$unit"
|
||||
done
|
||||
|
||||
# Verify enforcement is actually armed. enable can silently no-op when systemd
|
||||
# breaks an ordering cycle by deleting the timer's job -- exactly what happened
|
||||
|
||||
@ -14,7 +14,7 @@ pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-randomly==5.1.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
# Direct dependency since the Firebase session recovery exchanges a refresh
|
||||
|
||||
@ -62,7 +62,9 @@ Future<String?> _resolveCacheDir() async {
|
||||
final dir = await getApplicationCacheDirectory();
|
||||
return '${dir.path}/osm_tiles';
|
||||
} on Object catch (error) {
|
||||
logMapWarning('map tile cache unavailable, tiles will not persist: $error');
|
||||
final message = 'map tile cache unavailable, tiles not persisted: $error';
|
||||
developer.log(message, name: 'RunMap', level: 900);
|
||||
debugPrint('RunMap WARNING: $message');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,10 @@ const RunnerUpImportReport kNothingImported = (
|
||||
failures: [],
|
||||
);
|
||||
|
||||
/// The report of a pass that did nothing but fail for [reason].
|
||||
RunnerUpImportReport failedReport(String reason) =>
|
||||
(imported: 0, duplicates: 0, skipped: 0, failures: [reason]);
|
||||
|
||||
/// Counts one import pass and logs each failure as it happens.
|
||||
class ImportTally {
|
||||
/// Runs stored this pass.
|
||||
|
||||
@ -94,12 +94,7 @@ class RunnerUpImporter {
|
||||
} on Object catch (error) {
|
||||
debugPrint('WorkoutApp: RunnerUp import WARNING: aborted: $error');
|
||||
runnerUpImportStatus.value = RunnerUpImportStatus.failed;
|
||||
return (
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
skipped: 0,
|
||||
failures: ['aborted: $error'],
|
||||
);
|
||||
return failedReport('aborted: $error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/services/run_upload_credentials.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
group('provisioning', () {
|
||||
late Directory dir;
|
||||
setUp(() {
|
||||
installFakeSecureStorage();
|
||||
dir = Directory.systemTemp.createTempSync('provision_test_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
});
|
||||
|
||||
RunCredentialStore store() =>
|
||||
RunCredentialStore(externalDir: () async => dir);
|
||||
File file() => File('${dir.path}/$kProvisionFileName');
|
||||
|
||||
test('a login file is imported into secure storage and deleted', () async {
|
||||
file().writeAsStringSync(
|
||||
jsonEncode({
|
||||
'url': 'https://cloud.example/',
|
||||
'user': 'workout_app',
|
||||
'password': 'secret',
|
||||
}),
|
||||
);
|
||||
final s = store();
|
||||
expect(await s.read(), isNull);
|
||||
|
||||
final outcome = await s.importProvisionFile();
|
||||
|
||||
expect(outcome.status, ProvisionStatus.imported);
|
||||
expect(file().existsSync(), isFalse);
|
||||
final login = (await s.read())!;
|
||||
expect(login.url, 'https://cloud.example');
|
||||
expect(login.user, 'workout_app');
|
||||
expect(login.password, 'secret');
|
||||
expect(
|
||||
login.authorization,
|
||||
'Basic ${base64.encode(utf8.encode('workout_app:secret'))}',
|
||||
);
|
||||
});
|
||||
|
||||
test('no file, or no directory, is a quiet no-op', () async {
|
||||
expect(
|
||||
(await store().importProvisionFile()).status,
|
||||
ProvisionStatus.absent,
|
||||
);
|
||||
final none = RunCredentialStore(externalDir: () async => null);
|
||||
expect((await none.importProvisionFile()).status, ProvisionStatus.absent);
|
||||
// The default directory lookup answers null off Android.
|
||||
expect(
|
||||
(await RunCredentialStore().importProvisionFile()).status,
|
||||
ProvisionStatus.absent,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unusable file is kept and reported', () async {
|
||||
final bad = <String>[
|
||||
'not json',
|
||||
'[]',
|
||||
jsonEncode({'url': 'https://x', 'user': 'u'}),
|
||||
jsonEncode({'url': 'ftp://x', 'user': 'u', 'password': 'p'}),
|
||||
jsonEncode({'url': 'nonsense', 'user': 'u', 'password': 'p'}),
|
||||
jsonEncode({'url': 'https://x', 'user': '', 'password': 'p'}),
|
||||
jsonEncode({'url': 'https://x', 'user': 'u', 'password': ''}),
|
||||
];
|
||||
for (final text in bad) {
|
||||
file().writeAsStringSync(text);
|
||||
final s = store();
|
||||
final outcome = await s.importProvisionFile();
|
||||
expect(outcome.status, ProvisionStatus.invalid, reason: text);
|
||||
expect(outcome.reason, contains('provision_run_upload.sh'));
|
||||
expect(file().existsSync(), isTrue);
|
||||
expect(await s.read(), isNull);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/position_source.dart';
|
||||
import 'package:workout_app/services/run_recorder.dart';
|
||||
|
||||
import '../run_test_fixtures.dart';
|
||||
|
||||
RunRecorder _recorder(ManualPositionSource source, {List<int>? km}) =>
|
||||
RunRecorder(sourceFor: (_) => source, onKilometre: km?.add);
|
||||
|
||||
void main() {
|
||||
group('start', () {
|
||||
test('refusals stay idle and say why', () async {
|
||||
for (final (access, issue) in [
|
||||
(LocationAccess.denied, RunIssue.denied),
|
||||
(LocationAccess.deniedForever, RunIssue.deniedForever),
|
||||
(LocationAccess.serviceOff, RunIssue.serviceOff),
|
||||
]) {
|
||||
var served = false;
|
||||
final recorder = _recorder(ManualPositionSource(access: access));
|
||||
expect(
|
||||
await recorder.start(afterAccess: () async => served = true),
|
||||
isFalse,
|
||||
);
|
||||
expect(recorder.phase, RunPhase.idle);
|
||||
expect(recorder.issue, issue);
|
||||
expect(served, isFalse);
|
||||
recorder.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'afterAccess runs once access is held, and a retry can succeed',
|
||||
() async {
|
||||
final order = <String>[];
|
||||
final recorder = _recorder(ManualPositionSource());
|
||||
expect(
|
||||
await recorder.start(afterAccess: () async => order.add('service')),
|
||||
isTrue,
|
||||
);
|
||||
expect(order, ['service']);
|
||||
expect(recorder.issue, RunIssue.none);
|
||||
recorder.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('a double tap starts one recording', () async {
|
||||
final source = ManualPositionSource();
|
||||
final recorder = _recorder(source);
|
||||
final first = recorder.start();
|
||||
final second = recorder.start();
|
||||
expect(await first, isTrue);
|
||||
expect(await second, isFalse);
|
||||
expect(await recorder.start(), isFalse);
|
||||
expect(source.subscriptions, 1);
|
||||
recorder.dispose();
|
||||
});
|
||||
|
||||
test('the sport is chosen while idle only, and picks the source', () async {
|
||||
final seen = <RunSport>[];
|
||||
final recorder = RunRecorder(
|
||||
sourceFor: (sport) {
|
||||
seen.add(sport);
|
||||
return ManualPositionSource();
|
||||
},
|
||||
);
|
||||
expect(recorder.selectSport(RunSport.walking), isTrue);
|
||||
await recorder.start();
|
||||
expect(recorder.selectSport(RunSport.running), isFalse);
|
||||
expect(recorder.sport, RunSport.walking);
|
||||
expect(seen, [RunSport.walking]);
|
||||
recorder.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('trouble', () {
|
||||
test('a stream error is flagged and clears with the next fix', () async {
|
||||
final source = ManualPositionSource();
|
||||
final recorder = _recorder(source);
|
||||
await recorder.start();
|
||||
source.controller
|
||||
..add(fixAt(0, 0))
|
||||
..addError(StateError('gps off'));
|
||||
await pumpEventQueue();
|
||||
expect(recorder.issue, RunIssue.streamError);
|
||||
source.controller.add(fixAt(2, 6));
|
||||
await pumpEventQueue();
|
||||
expect(recorder.issue, RunIssue.none);
|
||||
recorder.dispose();
|
||||
});
|
||||
|
||||
test('finishing with under two fixes saves nothing', () async {
|
||||
final source = ManualPositionSource();
|
||||
final recorder = _recorder(source);
|
||||
await recorder.start();
|
||||
source.controller.add(fixAt(0, 0));
|
||||
await pumpEventQueue();
|
||||
expect(await recorder.finish(), isNull);
|
||||
expect(recorder.issue, RunIssue.tooShort);
|
||||
recorder.dispose();
|
||||
});
|
||||
|
||||
test('finish before any start is a no-op', () async {
|
||||
final recorder = _recorder(ManualPositionSource());
|
||||
expect(await recorder.finish(), isNull);
|
||||
expect(recorder.distanceM, 0);
|
||||
recorder.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/position_source.dart';
|
||||
import 'package:workout_app/services/position_source_scripted.dart';
|
||||
import 'package:workout_app/services/run_recorder.dart';
|
||||
|
||||
@ -179,103 +178,4 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('start', () {
|
||||
test('refusals stay idle and say why', () async {
|
||||
for (final (access, issue) in [
|
||||
(LocationAccess.denied, RunIssue.denied),
|
||||
(LocationAccess.deniedForever, RunIssue.deniedForever),
|
||||
(LocationAccess.serviceOff, RunIssue.serviceOff),
|
||||
]) {
|
||||
var served = false;
|
||||
final recorder = _recorder(ManualPositionSource(access: access));
|
||||
expect(
|
||||
await recorder.start(afterAccess: () async => served = true),
|
||||
isFalse,
|
||||
);
|
||||
expect(recorder.phase, RunPhase.idle);
|
||||
expect(recorder.issue, issue);
|
||||
expect(served, isFalse);
|
||||
recorder.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'afterAccess runs once access is held, and a retry can succeed',
|
||||
() async {
|
||||
final order = <String>[];
|
||||
final recorder = _recorder(ManualPositionSource());
|
||||
expect(
|
||||
await recorder.start(afterAccess: () async => order.add('service')),
|
||||
isTrue,
|
||||
);
|
||||
expect(order, ['service']);
|
||||
expect(recorder.issue, RunIssue.none);
|
||||
recorder.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('a double tap starts one recording', () async {
|
||||
final source = ManualPositionSource();
|
||||
final recorder = _recorder(source);
|
||||
final first = recorder.start();
|
||||
final second = recorder.start();
|
||||
expect(await first, isTrue);
|
||||
expect(await second, isFalse);
|
||||
expect(await recorder.start(), isFalse);
|
||||
expect(source.subscriptions, 1);
|
||||
recorder.dispose();
|
||||
});
|
||||
|
||||
test('the sport is chosen while idle only, and picks the source', () async {
|
||||
final seen = <RunSport>[];
|
||||
final recorder = RunRecorder(
|
||||
sourceFor: (sport) {
|
||||
seen.add(sport);
|
||||
return ManualPositionSource();
|
||||
},
|
||||
);
|
||||
expect(recorder.selectSport(RunSport.walking), isTrue);
|
||||
await recorder.start();
|
||||
expect(recorder.selectSport(RunSport.running), isFalse);
|
||||
expect(recorder.sport, RunSport.walking);
|
||||
expect(seen, [RunSport.walking]);
|
||||
recorder.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('trouble', () {
|
||||
test('a stream error is flagged and clears with the next fix', () async {
|
||||
final source = ManualPositionSource();
|
||||
final recorder = _recorder(source);
|
||||
await recorder.start();
|
||||
source.controller
|
||||
..add(fixAt(0, 0))
|
||||
..addError(StateError('gps off'));
|
||||
await pumpEventQueue();
|
||||
expect(recorder.issue, RunIssue.streamError);
|
||||
source.controller.add(fixAt(2, 6));
|
||||
await pumpEventQueue();
|
||||
expect(recorder.issue, RunIssue.none);
|
||||
recorder.dispose();
|
||||
});
|
||||
|
||||
test('finishing with under two fixes saves nothing', () async {
|
||||
final source = ManualPositionSource();
|
||||
final recorder = _recorder(source);
|
||||
await recorder.start();
|
||||
source.controller.add(fixAt(0, 0));
|
||||
await pumpEventQueue();
|
||||
expect(await recorder.finish(), isNull);
|
||||
expect(recorder.issue, RunIssue.tooShort);
|
||||
recorder.dispose();
|
||||
});
|
||||
|
||||
test('finish before any start is a no-op', () async {
|
||||
final recorder = _recorder(ManualPositionSource());
|
||||
expect(await recorder.finish(), isNull);
|
||||
expect(recorder.distanceM, 0);
|
||||
recorder.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/services/run_saver.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
import '../run_test_fixtures.dart';
|
||||
import 'run_uploader_support.dart';
|
||||
|
||||
void main() {
|
||||
useUploaderStorage();
|
||||
|
||||
group('saveRecordedRun', () {
|
||||
test('stores a pending app run and starts the upload', () async {
|
||||
var enqueued = 0;
|
||||
final track = sampleTrack();
|
||||
final result = await saveRecordedRun(
|
||||
track,
|
||||
storage: db,
|
||||
enqueue: () async {
|
||||
enqueued++;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
expect(result.stored, isTrue);
|
||||
expect(enqueued, 1);
|
||||
final record = (await db.getRun(track.id))!;
|
||||
expect(record.uploadState, RunUploadState.pending);
|
||||
expect(record.track.points, hasLength(3));
|
||||
expect(record.tcxFilename, endsWith('_Running.tcx'));
|
||||
});
|
||||
|
||||
test('a run already stored is reported and not queued twice', () async {
|
||||
var enqueued = 0;
|
||||
Future<Object?> enqueue() async {
|
||||
enqueued++;
|
||||
return null;
|
||||
}
|
||||
|
||||
final track = sampleTrack();
|
||||
await saveRecordedRun(track, storage: db, enqueue: enqueue);
|
||||
final again = await saveRecordedRun(track, storage: db, enqueue: enqueue);
|
||||
expect(again.stored, isFalse);
|
||||
expect(again.reason, contains('already stored'));
|
||||
expect(enqueued, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/services/dufs_credentials.dart';
|
||||
import 'package:workout_app/services/run_uploader.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
import '../run_test_fixtures.dart';
|
||||
import 'run_uploader_support.dart';
|
||||
|
||||
void main() {
|
||||
useUploaderStorage();
|
||||
|
||||
test('the sandbox keeps runs and never opens a connection', () async {
|
||||
Sandbox.enabled = true;
|
||||
final run = await saveRun('2026-11-09T12:00:00Z');
|
||||
final up = uploaderFor(
|
||||
MockClient((_) async => fail('the sandbox must never PUT')),
|
||||
store: FixedStore(testLogin),
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
expect(status.health, UploadHealth.sandbox);
|
||||
expect(status.pending, 1);
|
||||
expect(await stateOf(run.track.id), RunUploadState.pending);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('an empty queue is idle', () async {
|
||||
final up = uploaderFor(MockClient((_) async => fail('nothing to send')));
|
||||
final status = await up.uploadPending();
|
||||
expect(status.health, UploadHealth.idle);
|
||||
expect(status.pending, 0);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('concurrent calls share one pass', () async {
|
||||
await saveRun('2026-11-09T12:00:00Z');
|
||||
var calls = 0;
|
||||
final up = uploaderFor(
|
||||
MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('', 201);
|
||||
}),
|
||||
);
|
||||
final both = await Future.wait([up.uploadPending(), up.uploadPending()]);
|
||||
expect(calls, 1);
|
||||
expect(both.first, same(both.last));
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('a stored hash that no longer matches still uploads', () async {
|
||||
final track = sampleTrack();
|
||||
await db.insertRun(
|
||||
track,
|
||||
tcxFilename: tcxFilename(track),
|
||||
tcxSha256: 'stale',
|
||||
uploadState: RunUploadState.pending,
|
||||
);
|
||||
var calls = 0;
|
||||
final up = uploaderFor(
|
||||
MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('', 200);
|
||||
}),
|
||||
);
|
||||
await up.uploadPending();
|
||||
expect(calls, 1);
|
||||
expect(await stateOf(track.id), RunUploadState.uploaded);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'a run deleted mid-pass is skipped; one deleted mid-PUT is logged',
|
||||
() async {
|
||||
final first = await saveRun('2026-11-08T12:00:00Z');
|
||||
final second = await saveRun('2026-11-09T12:00:00Z');
|
||||
final seen = <String>[];
|
||||
final up = uploaderFor(
|
||||
MockClient((r) async {
|
||||
seen.add(r.url.pathSegments.last);
|
||||
await db.deleteRun(second.track.id);
|
||||
return http.Response('', 201);
|
||||
}),
|
||||
store: FixedStore(
|
||||
testLogin,
|
||||
onRead: () async {
|
||||
await db.deleteRun(first.track.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
expect(seen, [second.tcxFilename]);
|
||||
expect(status.health, UploadHealth.idle);
|
||||
up.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('a crashing keystore is reported, not thrown', () async {
|
||||
await saveRun('2026-11-09T12:00:00Z');
|
||||
final timers = <void Function()>[];
|
||||
final up = uploaderFor(
|
||||
MockClient((_) async => fail('no login, no PUT')),
|
||||
store: FixedStore(testLogin, throwing: true),
|
||||
timers: timers,
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
expect(status.health, UploadHealth.retrying);
|
||||
expect(status.reason, contains('crashed'));
|
||||
expect(timers, hasLength(1));
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('a queue with no injected client opens and closes its own', () async {
|
||||
await saveRun('2026-11-09T12:00:00Z');
|
||||
final up = RunUploader(
|
||||
credentials: FixedStore(
|
||||
DufsCredentials(url: 'http://127.0.0.1:1', user: 'u', password: 'p'),
|
||||
),
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
expect(status.health, UploadHealth.retrying);
|
||||
expect(RunUploader.instance.status.value.health, UploadHealth.idle);
|
||||
up.dispose();
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/services/dufs_credentials.dart';
|
||||
import 'package:workout_app/services/run_saver.dart';
|
||||
import 'package:workout_app/services/run_upload_credentials.dart';
|
||||
import 'package:workout_app/services/run_uploader.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
import '../run_test_fixtures.dart';
|
||||
|
||||
final testLogin = DufsCredentials(
|
||||
url: 'https://cloud.example',
|
||||
user: 'workout_app',
|
||||
password: 'pw',
|
||||
);
|
||||
|
||||
/// A store that already holds [testLogin] (or fails like a broken keystore).
|
||||
class FixedStore implements RunCredentialStore {
|
||||
FixedStore(this._creds, {this.onRead, this.throwing = false});
|
||||
|
||||
final DufsCredentials? _creds;
|
||||
final Future<void> Function()? onRead;
|
||||
final bool throwing;
|
||||
|
||||
@override
|
||||
Future<DufsCredentials?> read() async {
|
||||
await onRead?.call();
|
||||
if (throwing) throw PlatformException(code: 'keystore');
|
||||
return _creds;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProvisionOutcome> importProvisionFile() => throw UnimplementedError();
|
||||
}
|
||||
|
||||
StorageService get db => StorageService.instance;
|
||||
|
||||
Future<RunRecord> saveRun(String start) async {
|
||||
final track = sampleTrack(start: start);
|
||||
await saveRecordedRun(track, storage: db, enqueue: () async => null);
|
||||
return (await db.listRuns()).firstWhere((r) => r.track.id == track.id);
|
||||
}
|
||||
|
||||
Future<RunUploadState> stateOf(String id) async =>
|
||||
(await db.getRun(id))!.uploadState;
|
||||
|
||||
/// Opens a fresh in-memory run database around every test.
|
||||
void useUploaderStorage() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
Sandbox.enabled = false;
|
||||
StorageService.resetForTesting();
|
||||
});
|
||||
}
|
||||
|
||||
/// An uploader over [client] whose retry timer never fires on its own.
|
||||
RunUploader uploaderFor(
|
||||
http.Client client, {
|
||||
RunCredentialStore? store,
|
||||
List<Duration>? delays,
|
||||
List<void Function()>? timers,
|
||||
}) => RunUploader(
|
||||
credentials: store ?? FixedStore(testLogin),
|
||||
client: client,
|
||||
scheduler: (d, f) {
|
||||
delays?.add(d);
|
||||
timers?.add(f);
|
||||
return Timer(const Duration(days: 1), f);
|
||||
},
|
||||
);
|
||||
@ -1,96 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/services/dufs_credentials.dart';
|
||||
import 'package:workout_app/services/run_saver.dart';
|
||||
import 'package:workout_app/services/run_upload_credentials.dart';
|
||||
import 'package:workout_app/services/run_uploader.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../run_test_fixtures.dart';
|
||||
|
||||
final _login = DufsCredentials(
|
||||
url: 'https://cloud.example',
|
||||
user: 'workout_app',
|
||||
password: 'pw',
|
||||
);
|
||||
|
||||
/// A store that already holds [_login] (or fails like a broken keystore).
|
||||
class _FixedStore implements RunCredentialStore {
|
||||
_FixedStore(this._login, {this.onRead, this.throwing = false});
|
||||
|
||||
final DufsCredentials? _login;
|
||||
final Future<void> Function()? onRead;
|
||||
final bool throwing;
|
||||
|
||||
@override
|
||||
Future<DufsCredentials?> read() async {
|
||||
await onRead?.call();
|
||||
if (throwing) throw PlatformException(code: 'keystore');
|
||||
return _login;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProvisionOutcome> importProvisionFile() => throw UnimplementedError();
|
||||
}
|
||||
|
||||
StorageService get _db => StorageService.instance;
|
||||
|
||||
Future<RunRecord> _save(String start) async {
|
||||
final track = sampleTrack(start: start);
|
||||
await saveRecordedRun(track, storage: _db, enqueue: () async => null);
|
||||
return (await _db.listRuns()).firstWhere((r) => r.track.id == track.id);
|
||||
}
|
||||
|
||||
Future<RunUploadState> _stateOf(String id) async =>
|
||||
(await _db.getRun(id))!.uploadState;
|
||||
import 'run_uploader_support.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
Sandbox.enabled = false;
|
||||
StorageService.resetForTesting();
|
||||
});
|
||||
|
||||
RunUploader uploader(
|
||||
http.Client client, {
|
||||
RunCredentialStore? store,
|
||||
List<Duration>? delays,
|
||||
List<void Function()>? timers,
|
||||
}) => RunUploader(
|
||||
credentials: store ?? _FixedStore(_login),
|
||||
client: client,
|
||||
scheduler: (d, f) {
|
||||
delays?.add(d);
|
||||
timers?.add(f);
|
||||
return Timer(const Duration(days: 1), f);
|
||||
},
|
||||
);
|
||||
useUploaderStorage();
|
||||
|
||||
test('a 201 marks the run uploaded after a Basic-auth PUT', () async {
|
||||
final run = await _save('2026-11-09T12:00:00Z');
|
||||
final run = await saveRun('2026-11-09T12:00:00Z');
|
||||
late http.Request seen;
|
||||
final up = uploader(
|
||||
final up = uploaderFor(
|
||||
MockClient((r) async {
|
||||
seen = r;
|
||||
return http.Response('', 201);
|
||||
@ -108,19 +34,19 @@ void main() {
|
||||
seen.headers['Authorization'],
|
||||
'Basic ${base64.encode(utf8.encode('workout_app:pw'))}',
|
||||
);
|
||||
expect(seen.body, encodeTcx((await _db.getRun(run.track.id))!.track));
|
||||
expect(seen.body, encodeTcx((await db.getRun(run.track.id))!.track));
|
||||
expect(status.health, UploadHealth.idle);
|
||||
expect(status.pending, 0);
|
||||
expect(await _stateOf(run.track.id), RunUploadState.uploaded);
|
||||
expect(await stateOf(run.track.id), RunUploadState.uploaded);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('a 401 keeps the run pending and retries on a timer', () async {
|
||||
final run = await _save('2026-11-09T12:00:00Z');
|
||||
final run = await saveRun('2026-11-09T12:00:00Z');
|
||||
final delays = <Duration>[];
|
||||
final timers = <void Function()>[];
|
||||
var code = 401;
|
||||
final up = uploader(
|
||||
final up = uploaderFor(
|
||||
MockClient((_) async => http.Response('', code)),
|
||||
delays: delays,
|
||||
timers: timers,
|
||||
@ -132,39 +58,39 @@ void main() {
|
||||
expect(status.reason, contains('401'));
|
||||
expect(up.status.value, same(status));
|
||||
expect(delays, [kUploadRetryAfter]);
|
||||
expect(await _stateOf(run.track.id), RunUploadState.pending);
|
||||
expect(await stateOf(run.track.id), RunUploadState.pending);
|
||||
|
||||
code = 201;
|
||||
timers.single();
|
||||
// Joins the pass the timer just started (single flight).
|
||||
await up.uploadPending();
|
||||
expect(await _stateOf(run.track.id), RunUploadState.uploaded);
|
||||
expect(await stateOf(run.track.id), RunUploadState.uploaded);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('a network error or server fault stays pending', () async {
|
||||
final run = await _save('2026-11-09T12:00:00Z');
|
||||
final broken = uploader(
|
||||
final run = await saveRun('2026-11-09T12:00:00Z');
|
||||
final broken = uploaderFor(
|
||||
MockClient((_) async => throw http.ClientException('no route')),
|
||||
);
|
||||
final first = await broken.uploadPending();
|
||||
expect(first.health, UploadHealth.retrying);
|
||||
expect(first.reason, contains('no route'));
|
||||
|
||||
final faulty = uploader(MockClient((_) async => http.Response('', 503)));
|
||||
final faulty = uploaderFor(MockClient((_) async => http.Response('', 503)));
|
||||
final second = await faulty.uploadPending();
|
||||
expect(second.reason, contains('503'));
|
||||
expect(second.reason, isNot(contains('provision')));
|
||||
expect(await _stateOf(run.track.id), RunUploadState.pending);
|
||||
expect(await stateOf(run.track.id), RunUploadState.pending);
|
||||
broken.dispose();
|
||||
faulty.dispose();
|
||||
});
|
||||
|
||||
test('runs go oldest first and a failure stops the pass', () async {
|
||||
final older = await _save('2026-11-08T12:00:00Z');
|
||||
final newer = await _save('2026-11-09T12:00:00Z');
|
||||
final older = await saveRun('2026-11-08T12:00:00Z');
|
||||
final newer = await saveRun('2026-11-09T12:00:00Z');
|
||||
final urls = <String>[];
|
||||
final up = uploader(
|
||||
final up = uploaderFor(
|
||||
MockClient((r) async {
|
||||
urls.add(r.url.pathSegments.last);
|
||||
return http.Response('', 500);
|
||||
@ -173,22 +99,22 @@ void main() {
|
||||
final status = await up.uploadPending();
|
||||
expect(urls, [older.tcxFilename]);
|
||||
expect(status.pending, 2);
|
||||
expect(await _stateOf(newer.track.id), RunUploadState.pending);
|
||||
expect(await stateOf(newer.track.id), RunUploadState.pending);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'without a provisioned login nothing is sent and no retry runs',
|
||||
() async {
|
||||
await _save('2026-11-09T12:00:00Z');
|
||||
await saveRun('2026-11-09T12:00:00Z');
|
||||
final timers = <void Function()>[];
|
||||
var calls = 0;
|
||||
final up = uploader(
|
||||
final up = uploaderFor(
|
||||
MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('', 201);
|
||||
}),
|
||||
store: _FixedStore(null),
|
||||
store: FixedStore(null),
|
||||
timers: timers,
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
@ -199,229 +125,4 @@ void main() {
|
||||
up.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('the sandbox keeps runs and never opens a connection', () async {
|
||||
Sandbox.enabled = true;
|
||||
final run = await _save('2026-11-09T12:00:00Z');
|
||||
final up = uploader(
|
||||
MockClient((_) async => fail('the sandbox must never PUT')),
|
||||
store: _FixedStore(_login),
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
expect(status.health, UploadHealth.sandbox);
|
||||
expect(status.pending, 1);
|
||||
expect(await _stateOf(run.track.id), RunUploadState.pending);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('an empty queue is idle', () async {
|
||||
final up = uploader(MockClient((_) async => fail('nothing to send')));
|
||||
final status = await up.uploadPending();
|
||||
expect(status.health, UploadHealth.idle);
|
||||
expect(status.pending, 0);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('concurrent calls share one pass', () async {
|
||||
await _save('2026-11-09T12:00:00Z');
|
||||
var calls = 0;
|
||||
final up = uploader(
|
||||
MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('', 201);
|
||||
}),
|
||||
);
|
||||
final both = await Future.wait([up.uploadPending(), up.uploadPending()]);
|
||||
expect(calls, 1);
|
||||
expect(both.first, same(both.last));
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('a stored hash that no longer matches still uploads', () async {
|
||||
final track = sampleTrack();
|
||||
await _db.insertRun(
|
||||
track,
|
||||
tcxFilename: tcxFilename(track),
|
||||
tcxSha256: 'stale',
|
||||
uploadState: RunUploadState.pending,
|
||||
);
|
||||
var calls = 0;
|
||||
final up = uploader(
|
||||
MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('', 200);
|
||||
}),
|
||||
);
|
||||
await up.uploadPending();
|
||||
expect(calls, 1);
|
||||
expect(await _stateOf(track.id), RunUploadState.uploaded);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'a run deleted mid-pass is skipped; one deleted mid-PUT is logged',
|
||||
() async {
|
||||
final first = await _save('2026-11-08T12:00:00Z');
|
||||
final second = await _save('2026-11-09T12:00:00Z');
|
||||
final seen = <String>[];
|
||||
final up = uploader(
|
||||
MockClient((r) async {
|
||||
seen.add(r.url.pathSegments.last);
|
||||
await _db.deleteRun(second.track.id);
|
||||
return http.Response('', 201);
|
||||
}),
|
||||
store: _FixedStore(
|
||||
_login,
|
||||
onRead: () async {
|
||||
await _db.deleteRun(first.track.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
expect(seen, [second.tcxFilename]);
|
||||
expect(status.health, UploadHealth.idle);
|
||||
up.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('a crashing keystore is reported, not thrown', () async {
|
||||
await _save('2026-11-09T12:00:00Z');
|
||||
final timers = <void Function()>[];
|
||||
final up = uploader(
|
||||
MockClient((_) async => fail('no login, no PUT')),
|
||||
store: _FixedStore(_login, throwing: true),
|
||||
timers: timers,
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
expect(status.health, UploadHealth.retrying);
|
||||
expect(status.reason, contains('crashed'));
|
||||
expect(timers, hasLength(1));
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
test('a queue with no injected client opens and closes its own', () async {
|
||||
await _save('2026-11-09T12:00:00Z');
|
||||
final up = RunUploader(
|
||||
credentials: _FixedStore(
|
||||
DufsCredentials(url: 'http://127.0.0.1:1', user: 'u', password: 'p'),
|
||||
),
|
||||
);
|
||||
final status = await up.uploadPending();
|
||||
expect(status.health, UploadHealth.retrying);
|
||||
expect(RunUploader.instance.status.value.health, UploadHealth.idle);
|
||||
up.dispose();
|
||||
});
|
||||
|
||||
group('provisioning', () {
|
||||
late Directory dir;
|
||||
setUp(() {
|
||||
installFakeSecureStorage();
|
||||
dir = Directory.systemTemp.createTempSync('provision_test_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
});
|
||||
|
||||
RunCredentialStore store() =>
|
||||
RunCredentialStore(externalDir: () async => dir);
|
||||
File file() => File('${dir.path}/$kProvisionFileName');
|
||||
|
||||
test('a login file is imported into secure storage and deleted', () async {
|
||||
file().writeAsStringSync(
|
||||
jsonEncode({
|
||||
'url': 'https://cloud.example/',
|
||||
'user': 'workout_app',
|
||||
'password': 'secret',
|
||||
}),
|
||||
);
|
||||
final s = store();
|
||||
expect(await s.read(), isNull);
|
||||
|
||||
final outcome = await s.importProvisionFile();
|
||||
|
||||
expect(outcome.status, ProvisionStatus.imported);
|
||||
expect(file().existsSync(), isFalse);
|
||||
final login = (await s.read())!;
|
||||
expect(login.url, 'https://cloud.example');
|
||||
expect(login.user, 'workout_app');
|
||||
expect(login.password, 'secret');
|
||||
expect(
|
||||
login.authorization,
|
||||
'Basic ${base64.encode(utf8.encode('workout_app:secret'))}',
|
||||
);
|
||||
});
|
||||
|
||||
test('no file, or no directory, is a quiet no-op', () async {
|
||||
expect(
|
||||
(await store().importProvisionFile()).status,
|
||||
ProvisionStatus.absent,
|
||||
);
|
||||
final none = RunCredentialStore(externalDir: () async => null);
|
||||
expect((await none.importProvisionFile()).status, ProvisionStatus.absent);
|
||||
// The default directory lookup answers null off Android.
|
||||
expect(
|
||||
(await RunCredentialStore().importProvisionFile()).status,
|
||||
ProvisionStatus.absent,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unusable file is kept and reported', () async {
|
||||
final bad = <String>[
|
||||
'not json',
|
||||
'[]',
|
||||
jsonEncode({'url': 'https://x', 'user': 'u'}),
|
||||
jsonEncode({'url': 'ftp://x', 'user': 'u', 'password': 'p'}),
|
||||
jsonEncode({'url': 'nonsense', 'user': 'u', 'password': 'p'}),
|
||||
jsonEncode({'url': 'https://x', 'user': '', 'password': 'p'}),
|
||||
jsonEncode({'url': 'https://x', 'user': 'u', 'password': ''}),
|
||||
];
|
||||
for (final text in bad) {
|
||||
file().writeAsStringSync(text);
|
||||
final s = store();
|
||||
final outcome = await s.importProvisionFile();
|
||||
expect(outcome.status, ProvisionStatus.invalid, reason: text);
|
||||
expect(outcome.reason, contains('provision_run_upload.sh'));
|
||||
expect(file().existsSync(), isTrue);
|
||||
expect(await s.read(), isNull);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('saveRecordedRun', () {
|
||||
test('stores a pending app run and starts the upload', () async {
|
||||
var enqueued = 0;
|
||||
final track = sampleTrack();
|
||||
final result = await saveRecordedRun(
|
||||
track,
|
||||
storage: _db,
|
||||
enqueue: () async {
|
||||
enqueued++;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
expect(result.stored, isTrue);
|
||||
expect(enqueued, 1);
|
||||
final record = (await _db.getRun(track.id))!;
|
||||
expect(record.uploadState, RunUploadState.pending);
|
||||
expect(record.track.points, hasLength(3));
|
||||
expect(record.tcxFilename, endsWith('_Running.tcx'));
|
||||
});
|
||||
|
||||
test('a run already stored is reported and not queued twice', () async {
|
||||
var enqueued = 0;
|
||||
Future<Object?> enqueue() async {
|
||||
enqueued++;
|
||||
return null;
|
||||
}
|
||||
|
||||
final track = sampleTrack();
|
||||
await saveRecordedRun(track, storage: _db, enqueue: enqueue);
|
||||
final again = await saveRecordedRun(
|
||||
track,
|
||||
storage: _db,
|
||||
enqueue: enqueue,
|
||||
);
|
||||
expect(again.stored, isFalse);
|
||||
expect(again.reason, contains('already stored'));
|
||||
expect(enqueued, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
import '_runnerup_fake_dufs.dart';
|
||||
|
||||
StorageService get svc => StorageService.instance;
|
||||
|
||||
/// The three fixtures, decoded: two app-made, one RunnerUp export.
|
||||
List<(String, String, RunTrack)> tracks() => [
|
||||
for (final e in fixtureTcx().entries)
|
||||
(
|
||||
e.key,
|
||||
utf8.decode(e.value),
|
||||
decodeTcx(utf8.decode(e.value), fileName: e.key),
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> insertAll() async {
|
||||
for (final (name, xml, track) in tracks()) {
|
||||
final imported = track.source == RunSource.runnerupImport;
|
||||
await svc.insertRun(
|
||||
track,
|
||||
tcxFilename: name,
|
||||
tcxSha256: tcxSha256(xml),
|
||||
uploadState: imported ? RunUploadState.never : RunUploadState.pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> freshInstall() async {
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> backupOnceItHolds(int runs) async {
|
||||
for (var i = 0; i < 150; i++) {
|
||||
final b = await BackupService.instance.readBackup();
|
||||
if (b != null && ((b['runs'] as List?)?.length ?? 0) >= runs) return b;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('backup.json never held $runs run(s)');
|
||||
}
|
||||
|
||||
/// A fresh in-memory database and a throwaway backup directory per test.
|
||||
void useRunsTransferSandbox() {
|
||||
late Directory tmp;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('runs_transfer');
|
||||
BackupService.baseDir = tmp.path;
|
||||
await freshInstall();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
import 'storage_service_runs_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
useRunsTransferSandbox();
|
||||
|
||||
group('user export / merge-import', () {
|
||||
test(
|
||||
'round trip keeps every run and point; a re-import adds nothing',
|
||||
() async {
|
||||
await insertAll();
|
||||
final originals = {
|
||||
for (final r in await svc.listRuns())
|
||||
r.track.id: (await svc.getRun(r.track.id))!,
|
||||
};
|
||||
final data =
|
||||
jsonDecode(jsonEncode(await svc.exportData()))
|
||||
as Map<String, dynamic>;
|
||||
expect(data['version'], kExportVersion);
|
||||
expect(data['runs'], hasLength(3));
|
||||
|
||||
await freshInstall();
|
||||
final report = await svc.importDataReport(data);
|
||||
|
||||
expect(report.rows, greaterThanOrEqualTo(3));
|
||||
for (final run in originals.values) {
|
||||
final back = (await svc.getRun(run.track.id))!;
|
||||
expect(back.track.points, run.track.points);
|
||||
expect(back.track.source, run.track.source);
|
||||
expect(back.uploadState, run.uploadState);
|
||||
}
|
||||
final again = await svc.importDataReport(data);
|
||||
expect(again.rows, 0);
|
||||
expect(await svc.listRuns(), hasLength(3));
|
||||
},
|
||||
);
|
||||
|
||||
test('an old export without runs still imports', () async {
|
||||
final report = await svc.importDataReport({
|
||||
'format': kExportFormat,
|
||||
'version': 2,
|
||||
'settings': <Object?>[],
|
||||
});
|
||||
expect(report.rows, 0);
|
||||
expect(await svc.listRuns(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'a runs entry that is not a list is skipped, the rest merges',
|
||||
() async {
|
||||
final report = await svc.importDataReport({
|
||||
'settings': <Object?>[],
|
||||
'runs': 5,
|
||||
});
|
||||
expect(report.rows, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('an export holding only runs is accepted; junk is not', () async {
|
||||
await insertAll();
|
||||
final runs = (await svc.exportData())['runs']! as List<dynamic>;
|
||||
await freshInstall();
|
||||
expect((await svc.importDataReport({'runs': runs})).rows, 3);
|
||||
await expectLater(
|
||||
svc.importDataReport({'runs': 'nope'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'bad entries are skipped, good ones kept, imports stay never',
|
||||
() async {
|
||||
await insertAll();
|
||||
final good = ((await svc.exportData())['runs']! as List<dynamic>)
|
||||
.cast<Map<String, Object?>>();
|
||||
await freshInstall();
|
||||
final forged = {
|
||||
...good.first,
|
||||
'source': 'runnerup_import',
|
||||
'upload_state': 'pending',
|
||||
};
|
||||
final clash = {...good[1], 'id': 'other-id'}; // same hash, new id
|
||||
final badPoints = {...good[2], 'points': 'not json'};
|
||||
final badSport = {...good[2], 'id': 'z', 'sport': 'Cycling'};
|
||||
final noId = {...good[2], 'id': ''};
|
||||
|
||||
final report = await svc.importDataReport({
|
||||
'runs': [forged, good[1], clash, badPoints, badSport, noId, 'str', 7],
|
||||
});
|
||||
|
||||
expect(report.rows, 2);
|
||||
final stored = await svc.listRuns();
|
||||
expect(stored, hasLength(2));
|
||||
final coerced = stored.firstWhere((r) => r.track.id == forged['id']);
|
||||
expect(coerced.track.source, RunSource.runnerupImport);
|
||||
expect(coerced.uploadState, RunUploadState.never);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -2,85 +2,29 @@
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
import '_runnerup_fake_dufs.dart';
|
||||
|
||||
StorageService get _svc => StorageService.instance;
|
||||
|
||||
/// The three fixtures, decoded: two app-made, one RunnerUp export.
|
||||
List<(String, String, RunTrack)> _tracks() => [
|
||||
for (final e in fixtureTcx().entries)
|
||||
(
|
||||
e.key,
|
||||
utf8.decode(e.value),
|
||||
decodeTcx(utf8.decode(e.value), fileName: e.key),
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> _insertAll() async {
|
||||
for (final (name, xml, track) in _tracks()) {
|
||||
final imported = track.source == RunSource.runnerupImport;
|
||||
await _svc.insertRun(
|
||||
track,
|
||||
tcxFilename: name,
|
||||
tcxSha256: tcxSha256(xml),
|
||||
uploadState: imported ? RunUploadState.never : RunUploadState.pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _freshInstall() async {
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _backupOnceItHolds(int runs) async {
|
||||
for (var i = 0; i < 150; i++) {
|
||||
final b = await BackupService.instance.readBackup();
|
||||
if (b != null && ((b['runs'] as List?)?.length ?? 0) >= runs) return b;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('backup.json never held $runs run(s)');
|
||||
}
|
||||
import 'storage_service_runs_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('runs_transfer');
|
||||
BackupService.baseDir = tmp.path;
|
||||
await _freshInstall();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
useRunsTransferSandbox();
|
||||
|
||||
group('backup.json', () {
|
||||
test(
|
||||
'wipe the DB, restore: recorded runs and their points return',
|
||||
() async {
|
||||
await _insertAll();
|
||||
final backup = await _backupOnceItHolds(2);
|
||||
await insertAll();
|
||||
final backup = await backupOnceItHolds(2);
|
||||
final saved = {
|
||||
for (final r in await _svc.listRuns())
|
||||
for (final r in await svc.listRuns())
|
||||
if (r.track.source == RunSource.app)
|
||||
r.track.id: await _svc.getRun(r.track.id),
|
||||
r.track.id: await svc.getRun(r.track.id),
|
||||
};
|
||||
expect(saved, hasLength(2));
|
||||
// RunnerUp history is reproducible from the PC: not duplicated here.
|
||||
@ -95,14 +39,14 @@ void main() {
|
||||
reason: 'points must stay one string per run, not one line per fix',
|
||||
);
|
||||
|
||||
await _freshInstall(); // the reinstall
|
||||
expect(await _svc.listRuns(), isEmpty);
|
||||
await _svc.restoreFromBackupIfNeeded();
|
||||
await freshInstall(); // the reinstall
|
||||
expect(await svc.listRuns(), isEmpty);
|
||||
await svc.restoreFromBackupIfNeeded();
|
||||
|
||||
final restored = await _svc.listRuns();
|
||||
final restored = await svc.listRuns();
|
||||
expect(restored.map((r) => r.track.id).toSet(), saved.keys.toSet());
|
||||
for (final run in saved.values) {
|
||||
final back = (await _svc.getRun(run!.track.id))!;
|
||||
final back = (await svc.getRun(run!.track.id))!;
|
||||
expect(back.track.points, run.track.points);
|
||||
expect(back.track.points, isNotEmpty);
|
||||
expect(back.uploadState, run.uploadState);
|
||||
@ -114,23 +58,23 @@ void main() {
|
||||
);
|
||||
|
||||
test('restoring twice stores nothing twice', () async {
|
||||
await _insertAll();
|
||||
await _backupOnceItHolds(2);
|
||||
await _freshInstall();
|
||||
await _svc.restoreFromBackupIfNeeded();
|
||||
final once = await _svc.listRuns();
|
||||
await insertAll();
|
||||
await backupOnceItHolds(2);
|
||||
await freshInstall();
|
||||
await svc.restoreFromBackupIfNeeded();
|
||||
final once = await svc.listRuns();
|
||||
|
||||
await _svc.restoreFromBackupIfNeeded();
|
||||
await svc.restoreFromBackupIfNeeded();
|
||||
|
||||
expect(await _svc.listRuns(), hasLength(once.length));
|
||||
expect(await svc.listRuns(), hasLength(once.length));
|
||||
});
|
||||
|
||||
test('a run-less fresh DB cannot overwrite a runs-only backup', () async {
|
||||
await _insertAll();
|
||||
final before = await _backupOnceItHolds(2);
|
||||
await _freshInstall();
|
||||
await insertAll();
|
||||
final before = await backupOnceItHolds(2);
|
||||
await freshInstall();
|
||||
|
||||
await _svc.setLastWorkoutType('A'); // triggers _backupNow
|
||||
await svc.setLastWorkoutType('A'); // triggers _backupNow
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final after = await BackupService.instance.readBackup();
|
||||
@ -157,11 +101,11 @@ void main() {
|
||||
],
|
||||
'settings': <Object?>[],
|
||||
});
|
||||
final (name, xml, track) = _tracks().firstWhere(
|
||||
final (name, xml, track) = tracks().firstWhere(
|
||||
(t) => t.$3.source == RunSource.app,
|
||||
);
|
||||
|
||||
await _svc.insertRun(
|
||||
await svc.insertRun(
|
||||
track,
|
||||
tcxFilename: name,
|
||||
tcxSha256: tcxSha256(xml),
|
||||
@ -173,98 +117,4 @@ void main() {
|
||||
expect(after!['workout_history'], hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
group('user export / merge-import', () {
|
||||
test(
|
||||
'round trip keeps every run and point; a re-import adds nothing',
|
||||
() async {
|
||||
await _insertAll();
|
||||
final originals = {
|
||||
for (final r in await _svc.listRuns())
|
||||
r.track.id: (await _svc.getRun(r.track.id))!,
|
||||
};
|
||||
final data =
|
||||
jsonDecode(jsonEncode(await _svc.exportData()))
|
||||
as Map<String, dynamic>;
|
||||
expect(data['version'], kExportVersion);
|
||||
expect(data['runs'], hasLength(3));
|
||||
|
||||
await _freshInstall();
|
||||
final report = await _svc.importDataReport(data);
|
||||
|
||||
expect(report.rows, greaterThanOrEqualTo(3));
|
||||
for (final run in originals.values) {
|
||||
final back = (await _svc.getRun(run.track.id))!;
|
||||
expect(back.track.points, run.track.points);
|
||||
expect(back.track.source, run.track.source);
|
||||
expect(back.uploadState, run.uploadState);
|
||||
}
|
||||
final again = await _svc.importDataReport(data);
|
||||
expect(again.rows, 0);
|
||||
expect(await _svc.listRuns(), hasLength(3));
|
||||
},
|
||||
);
|
||||
|
||||
test('an old export without runs still imports', () async {
|
||||
final report = await _svc.importDataReport({
|
||||
'format': kExportFormat,
|
||||
'version': 2,
|
||||
'settings': <Object?>[],
|
||||
});
|
||||
expect(report.rows, 0);
|
||||
expect(await _svc.listRuns(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'a runs entry that is not a list is skipped, the rest merges',
|
||||
() async {
|
||||
final report = await _svc.importDataReport({
|
||||
'settings': <Object?>[],
|
||||
'runs': 5,
|
||||
});
|
||||
expect(report.rows, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('an export holding only runs is accepted; junk is not', () async {
|
||||
await _insertAll();
|
||||
final runs = (await _svc.exportData())['runs']! as List<dynamic>;
|
||||
await _freshInstall();
|
||||
expect((await _svc.importDataReport({'runs': runs})).rows, 3);
|
||||
await expectLater(
|
||||
_svc.importDataReport({'runs': 'nope'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'bad entries are skipped, good ones kept, imports stay never',
|
||||
() async {
|
||||
await _insertAll();
|
||||
final good = ((await _svc.exportData())['runs']! as List<dynamic>)
|
||||
.cast<Map<String, Object?>>();
|
||||
await _freshInstall();
|
||||
final forged = {
|
||||
...good.first,
|
||||
'source': 'runnerup_import',
|
||||
'upload_state': 'pending',
|
||||
};
|
||||
final clash = {...good[1], 'id': 'other-id'}; // same hash, new id
|
||||
final badPoints = {...good[2], 'points': 'not json'};
|
||||
final badSport = {...good[2], 'id': 'z', 'sport': 'Cycling'};
|
||||
final noId = {...good[2], 'id': ''};
|
||||
|
||||
final report = await _svc.importDataReport({
|
||||
'runs': [forged, good[1], clash, badPoints, badSport, noId, 'str', 7],
|
||||
});
|
||||
|
||||
expect(report.rows, 2);
|
||||
final stored = await _svc.listRuns();
|
||||
expect(stored, hasLength(2));
|
||||
final coerced = stored.firstWhere((r) => r.track.id == forged['id']);
|
||||
expect(coerced.track.source, RunSource.runnerupImport);
|
||||
expect(coerced.uploadState, RunUploadState.never);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user