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:
Krzysztof Rudnicki 2026-11-10 17:31:18 +01:00
parent 7f9169b9d0
commit 5d3547c221
15 changed files with 701 additions and 623 deletions

View File

@ -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

View File

@ -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

View File

@ -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;
}
}

View File

@ -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.

View File

@ -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');
}
}

View File

@ -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);
}
});
});
}

View File

@ -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();
});
});
}

View File

@ -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();
});
});
}

View File

@ -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);
});
});
}

View File

@ -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();
});
}

View File

@ -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);
},
);

View File

@ -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);
});
});
}

View File

@ -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);
});
}

View File

@ -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);
},
);
});
}

View File

@ -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);
},
);
});
}