feat(train): the person's workouts, sealed and synced (ADR 0030, D14)
WorkoutRepository keeps a workout with its series, sealed with the person's key: a workout for its row, a series chunk for its row and its workout, so the instance can neither read them nor move a chunk to another workout. A readable or changed row shows locked, never as the person's; a device without the account's keys saves nothing. On the device's database a workout and its series are written in one transaction, and a deleted one is blanked before it goes, so the sync engine's tombstones hold nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
61fc5c5f7c
commit
d1daec9c0b
8 changed files with 801 additions and 0 deletions
|
|
@ -0,0 +1,40 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/content_key_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [ContentKeyGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
class SyncContentKeyGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements ContentKeyGateway {
|
||||
@override
|
||||
Future<List<ContentKeyRow>> all() async => [
|
||||
for (final key in await protocol.ContentKey.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
(
|
||||
dataKeyId: key.dataKeyId,
|
||||
sealedKey: Uint8List.sublistView(key.sealedKey),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required Uint8List sealedKey,
|
||||
}) async {
|
||||
await protocol.ContentKey.db.insertRow(
|
||||
_db,
|
||||
protocol.ContentKey(
|
||||
dataKeyId: dataKeyId,
|
||||
sealedKey: ByteData.sublistView(sealedKey),
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [WorkoutGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
class SyncWorkoutGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements WorkoutGateway {
|
||||
@override
|
||||
Future<List<WorkoutRow>> workouts() async => [
|
||||
for (final workout in await protocol.Workout.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
(
|
||||
id: workout.id!.uuid,
|
||||
envelope: (
|
||||
keyId: workout.keyId,
|
||||
nonce: workout.nonce,
|
||||
payload: workout.payload,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<SeriesRow>> seriesOf(String workoutId) async {
|
||||
final protocol.UuidValue uuid;
|
||||
try {
|
||||
uuid = protocol.UuidValue.withValidation(workoutId);
|
||||
} on FormatException {
|
||||
// A route can name anything; what is no id names no workout.
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final series in await protocol.WorkoutSeries.db.find(
|
||||
_db,
|
||||
where: (t) => t.workoutId.equals(uuid),
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
(
|
||||
id: series.id!.uuid,
|
||||
workoutId: workoutId,
|
||||
envelope: (
|
||||
keyId: series.keyId,
|
||||
nonce: series.nonce,
|
||||
payload: series.payload,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(WorkoutRow workout, List<SeriesRow> series) async {
|
||||
await _db.db.transaction((transaction) async {
|
||||
await protocol.Workout.db.insertRow(
|
||||
_db,
|
||||
protocol.Workout(
|
||||
id: protocol.UuidValue.withValidation(workout.id),
|
||||
keyId: workout.envelope.keyId,
|
||||
nonce: workout.envelope.nonce,
|
||||
payload: workout.envelope.payload,
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (series.isNotEmpty) {
|
||||
await protocol.WorkoutSeries.db.insert(_db, [
|
||||
for (final row in series)
|
||||
protocol.WorkoutSeries(
|
||||
id: protocol.UuidValue.withValidation(row.id),
|
||||
workoutId: protocol.UuidValue.withValidation(row.workoutId),
|
||||
keyId: row.envelope.keyId,
|
||||
nonce: row.envelope.nonce,
|
||||
payload: row.envelope.payload,
|
||||
),
|
||||
], transaction: transaction);
|
||||
}
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (await protocol.Workout.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
)
|
||||
case final workout?) {
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.Workout.db.updateRow(
|
||||
_db,
|
||||
workout.copyWith(
|
||||
keyId: envelope.keyId,
|
||||
nonce: envelope.nonce,
|
||||
payload: envelope.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
final uuid = protocol.UuidValue.withValidation(id);
|
||||
await _db.db.transaction((transaction) async {
|
||||
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||
// they last were, until the space goes.
|
||||
final series = await protocol.WorkoutSeries.db.find(
|
||||
_db,
|
||||
where: (t) => t.workoutId.equals(uuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
for (final row in series) {
|
||||
await protocol.WorkoutSeries.db.updateRow(
|
||||
_db,
|
||||
protocol.WorkoutSeries(
|
||||
id: row.id,
|
||||
spaceId: row.spaceId,
|
||||
workoutId: row.workoutId,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
if (await protocol.Workout.db.findById(
|
||||
_db,
|
||||
uuid,
|
||||
transaction: transaction,
|
||||
)
|
||||
case final workout?) {
|
||||
await protocol.Workout.db.updateRow(
|
||||
_db,
|
||||
protocol.Workout(
|
||||
id: workout.id,
|
||||
spaceId: workout.spaceId,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.WorkoutSeries.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.workoutId.equals(uuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
await protocol.Workout.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(uuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// A `workout` row as kept: its id and its envelope, sealed (ADR 0030, D7).
|
||||
typedef WorkoutRow = ({String id, Envelope envelope});
|
||||
|
||||
/// A `workout_series` row as kept: its id, its workout's and its envelope.
|
||||
typedef SeriesRow = ({String id, String workoutId, Envelope envelope});
|
||||
|
||||
/// Where the person's workouts are kept: their synced `workout` and
|
||||
/// `workout_series` rows. The repository seals and opens; this only keeps.
|
||||
abstract interface class WorkoutGateway() {
|
||||
/// Every workout row.
|
||||
Future<List<WorkoutRow>> workouts();
|
||||
|
||||
/// The series rows of the workout [workoutId].
|
||||
Future<List<SeriesRow>> seriesOf(String workoutId);
|
||||
|
||||
/// Keeps a new workout with its series, together or not at all.
|
||||
Future<void> insert(WorkoutRow workout, List<SeriesRow> series);
|
||||
|
||||
/// Replaces the workout [id]'s envelope (an edit); nothing for one gone.
|
||||
Future<void> update(String id, Envelope envelope);
|
||||
|
||||
/// Deletes the workout [id] with its series, every payload blanked first
|
||||
/// (ADR 0002, rule 2): the sync engine keeps deleted rows as they last
|
||||
/// were.
|
||||
Future<void> delete(String id);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' show Uuid;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/series_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// The person's workouts (ADR 0030, D14), sealed with their key on the way to
|
||||
/// the instance and opened on the way back (D7): a workout for its row, a
|
||||
/// series chunk for its row and its workout, so the instance can neither
|
||||
/// read them nor move a chunk to another workout. Nothing readable is ever
|
||||
/// taken as the person's: Train writes nothing unsealed, so a readable row
|
||||
/// is one the instance made up, and shows locked.
|
||||
class WorkoutRepository(
|
||||
final WorkoutGateway _gateway,
|
||||
final TrainKeys _keys,
|
||||
) {
|
||||
StreamSubscription<void>? _remote;
|
||||
|
||||
/// This device's changes, and those a sync brings while anyone listens.
|
||||
late final StreamController<void> _changes = StreamController.broadcast(
|
||||
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
|
||||
onCancel: () => _remote?.cancel(),
|
||||
);
|
||||
|
||||
late final _envelope = PayloadEnvelope(_keys.cipher);
|
||||
|
||||
/// Fires when the workouts changed, here or on the person's other devices.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// Every workout, in the order they were made; one that doesn't open here
|
||||
/// with its content null.
|
||||
Future<List<StoredWorkout>> all() async {
|
||||
// Keys may have come with a sync.
|
||||
await _keys.refresh();
|
||||
final rows = await _gateway.workouts()
|
||||
..sort((a, b) => a.id.compareTo(b.id));
|
||||
return [
|
||||
for (final row in rows)
|
||||
StoredWorkout(
|
||||
row.id,
|
||||
switch (_open(row.envelope, table: 'workout', id: row.id)) {
|
||||
final payload? => decodeWorkout(payload),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The series of the workout [workoutId] that open here, in time order.
|
||||
Future<List<SeriesChunk>> series(String workoutId) async {
|
||||
await _keys.refresh();
|
||||
return [
|
||||
for (final row in await _gateway.seriesOf(workoutId))
|
||||
if (_open(
|
||||
row.envelope,
|
||||
table: 'workout_series',
|
||||
id: row.id,
|
||||
bound: 'workout:$workoutId',
|
||||
)
|
||||
case final payload?)
|
||||
?decodeSeries(payload),
|
||||
]..sort((a, b) => a.start.compareTo(b.start));
|
||||
}
|
||||
|
||||
/// Keeps a new workout with its [series] and returns its id; throws
|
||||
/// [KeysMissing] where this device can't seal.
|
||||
Future<String> save(WorkoutContent content, List<SeriesChunk> series) async {
|
||||
final keyId = await _keys.keyToWrite();
|
||||
final id = const Uuid().v7();
|
||||
await _gateway.insert(
|
||||
(
|
||||
id: id,
|
||||
envelope: _envelope.seal(
|
||||
encodeWorkout(content),
|
||||
keyId: keyId,
|
||||
table: 'workout',
|
||||
id: id,
|
||||
),
|
||||
),
|
||||
[
|
||||
for (final chunk in series)
|
||||
_sealedSeries(const Uuid().v7(), id, chunk, keyId),
|
||||
],
|
||||
);
|
||||
_changes.add(null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Replaces what the workout [id] is (its name, notes, totals); its series
|
||||
/// stay as they are.
|
||||
Future<void> update(String id, WorkoutContent content) async {
|
||||
final keyId = await _keys.keyToWrite();
|
||||
await _gateway.update(
|
||||
id,
|
||||
_envelope.seal(
|
||||
encodeWorkout(content),
|
||||
keyId: keyId,
|
||||
table: 'workout',
|
||||
id: id,
|
||||
),
|
||||
);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes the workout [id] with its series.
|
||||
Future<void> delete(String id) async {
|
||||
await _gateway.delete(id);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Closes what it holds open.
|
||||
Future<void> dispose() => _changes.close();
|
||||
|
||||
SeriesRow _sealedSeries(
|
||||
String id,
|
||||
String workoutId,
|
||||
SeriesChunk chunk,
|
||||
String keyId,
|
||||
) => (
|
||||
id: id,
|
||||
workoutId: workoutId,
|
||||
envelope: _envelope.seal(
|
||||
encodeSeries(chunk),
|
||||
keyId: keyId,
|
||||
table: 'workout_series',
|
||||
id: id,
|
||||
bound: 'workout:$workoutId',
|
||||
),
|
||||
);
|
||||
|
||||
/// The payload of [envelope], opened where it is sealed and opens for
|
||||
/// where it is kept; null otherwise, a readable one included.
|
||||
ByteData? _open(
|
||||
Envelope envelope, {
|
||||
required String table,
|
||||
required String id,
|
||||
String bound = '',
|
||||
}) {
|
||||
if (envelope.keyId == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_envelope.open(
|
||||
envelope,
|
||||
table: table,
|
||||
id: id,
|
||||
bound: bound,
|
||||
)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// A workout as the person's devices keep it: its id and what it is, or
|
||||
/// [content] null where it doesn't open here (sealed under a key this device
|
||||
/// lacks, changed, or moved by the instance): shown as locked, never half.
|
||||
final class const StoredWorkout(
|
||||
final String id,
|
||||
final WorkoutContent? content,
|
||||
) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, content];
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
// The workouts on the device's own database, as the sync engine keeps them,
|
||||
// with libsodium as its build hook compiles it.
|
||||
|
||||
final _person = protocol.UuidValue.withValidation(
|
||||
'01a16000-0000-7000-8000-000000000001',
|
||||
);
|
||||
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late Directory folder;
|
||||
late OfflineSyncDatabaseSession db;
|
||||
late SyncService sync;
|
||||
late TrainKeys keys;
|
||||
late WorkoutRepository workouts;
|
||||
|
||||
final start = DateTime.utc(2026, 10, 28, 7, 30);
|
||||
final ride = WorkoutContent(
|
||||
activity: Activity.cycling,
|
||||
startedAt: start,
|
||||
endedAt: start.add(const Duration(minutes: 30)),
|
||||
);
|
||||
final positions = chunksOf(
|
||||
SeriesKind.position,
|
||||
start: start,
|
||||
times: [for (var s = 0; s < 30 * 60; s++) s * 1000],
|
||||
columns: {
|
||||
'lat': [for (var s = 0; s < 30 * 60; s++) 525200000 + s * 40],
|
||||
'lon': [for (var s = 0; s < 30 * 60; s++) 134050000 + s * 25],
|
||||
},
|
||||
);
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('workouts');
|
||||
db = await protocol.Client(
|
||||
'http://127.0.0.1:9/',
|
||||
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
|
||||
sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
keys = TrainKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
() async => alice,
|
||||
);
|
||||
await keys.refresh();
|
||||
workouts = WorkoutRepository(SyncWorkoutGateway(db, sync), keys);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await workouts.dispose();
|
||||
keys.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
await folder.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('a workout and its series are kept, and read back opened', () async {
|
||||
final id = await workouts.save(ride, positions);
|
||||
|
||||
expect(await workouts.all(), [StoredWorkout(id, ride)]);
|
||||
expect(await workouts.series(id), positions);
|
||||
expect(positions, hasLength(3));
|
||||
});
|
||||
|
||||
test("its key is kept too, so the person's other devices open it", () async {
|
||||
final id = await workouts.save(ride, positions);
|
||||
|
||||
final laptop = TrainKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
() async => alice,
|
||||
);
|
||||
addTearDown(laptop.dispose);
|
||||
await laptop.refresh();
|
||||
final elsewhere = WorkoutRepository(SyncWorkoutGateway(db, sync), laptop);
|
||||
addTearDown(elsewhere.dispose);
|
||||
|
||||
expect(await elsewhere.all(), [StoredWorkout(id, ride)]);
|
||||
});
|
||||
|
||||
test('an edit changes the workout alone', () async {
|
||||
final id = await workouts.save(ride, positions);
|
||||
final named = WorkoutContent(
|
||||
activity: ride.activity,
|
||||
startedAt: ride.startedAt,
|
||||
endedAt: ride.endedAt,
|
||||
name: 'To the lake',
|
||||
);
|
||||
|
||||
await workouts.update(id, named);
|
||||
|
||||
expect(await workouts.all(), [StoredWorkout(id, named)]);
|
||||
expect(await workouts.series(id), positions);
|
||||
});
|
||||
|
||||
test('deleting a workout deletes it with its series, every payload '
|
||||
'blanked first', () async {
|
||||
final id = await workouts.save(ride, positions);
|
||||
final kept = await workouts.save(ride, positions.take(1).toList());
|
||||
|
||||
await workouts.delete(id);
|
||||
|
||||
expect(await workouts.all(), [StoredWorkout(kept, ride)]);
|
||||
expect(await workouts.series(id), isEmpty);
|
||||
// The database keeps deleted rows as the sync engine's tombstones, as
|
||||
// the instance does: past the engine, they hold nothing but blanks.
|
||||
for (final (table, live) in [('workout', 1), ('workout_series', 1)]) {
|
||||
final rows = await db.db.unsafeQuery(
|
||||
'SELECT length("payload"), "keyId", "nonce" FROM "$table"',
|
||||
);
|
||||
final blank = [
|
||||
for (final row in rows)
|
||||
if (row[0] == 0) row,
|
||||
];
|
||||
expect(rows.length - blank.length, live, reason: table);
|
||||
expect(blank, hasLength(table == 'workout' ? 1 : 3), reason: table);
|
||||
expect(blank.every((row) => row[1] == null && row[2] == null), isTrue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_workout_gateway.dart';
|
||||
|
||||
/// The person's workouts, sealed on the way to the instance and opened on
|
||||
/// the way back (ADR 0030, D7 and D14), with libsodium as its build hook
|
||||
/// compiles it.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemoryWorkoutGateway rows;
|
||||
final opened = <TrainKeys>[];
|
||||
|
||||
final start = DateTime.utc(2026, 10, 28, 7, 30);
|
||||
final run = WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
endedAt: start.add(const Duration(minutes: 25)),
|
||||
name: 'Along the river',
|
||||
distanceMetres: 5100,
|
||||
);
|
||||
final heartRate = chunksOf(
|
||||
SeriesKind.heartRate,
|
||||
start: start,
|
||||
times: [for (var s = 0; s < 25 * 60; s += 10) s * 1000],
|
||||
columns: {
|
||||
'bpm': [for (var s = 0; s < 25 * 60; s += 10) 110 + s % 50],
|
||||
},
|
||||
);
|
||||
|
||||
/// A device of the account whose keys are [account] (none: signed in
|
||||
/// without them), on the shared rows.
|
||||
Future<WorkoutRepository> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return WorkoutRepository(rows, keys);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemoryWorkoutGateway();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
test('a workout saved on one device reads on another of the account, its '
|
||||
'series in time order', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate.reversed.toList());
|
||||
|
||||
final laptop = await device(alice);
|
||||
|
||||
expect(await laptop.all(), [StoredWorkout(id, run)]);
|
||||
expect(await laptop.series(id), heartRate);
|
||||
});
|
||||
|
||||
test('nothing the instance gets is readable: every row is sealed, no '
|
||||
'name, no JSON', () async {
|
||||
final phone = await device(alice);
|
||||
await phone.save(run, heartRate);
|
||||
|
||||
expect(rows.written, hasLength(1 + heartRate.length));
|
||||
for (final envelope in rows.written) {
|
||||
expect(envelope.keyId, startsWith('train1.'));
|
||||
expect(envelope.nonce, isNotNull);
|
||||
final bytes = Uint8List.sublistView(envelope.payload);
|
||||
expect(
|
||||
utf8.decode(bytes, allowMalformed: true),
|
||||
isNot(contains('river')),
|
||||
);
|
||||
expect(bytes.take(2), isNot([0x1f, 0x8b]));
|
||||
}
|
||||
});
|
||||
|
||||
test('an edit replaces the workout and keeps its series', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate);
|
||||
final renamed = WorkoutContent(
|
||||
activity: run.activity,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
name: 'Morning run',
|
||||
distanceMetres: run.distanceMetres,
|
||||
);
|
||||
|
||||
await phone.update(id, renamed);
|
||||
|
||||
expect(await phone.all(), [StoredWorkout(id, renamed)]);
|
||||
expect(await phone.series(id), heartRate);
|
||||
});
|
||||
|
||||
test("a device without the account's keys sees workouts locked and saves "
|
||||
'none', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate);
|
||||
final writes = rows.written.length;
|
||||
|
||||
final elsewhere = await device(null);
|
||||
|
||||
expect(await elsewhere.all(), [StoredWorkout(id, null)]);
|
||||
expect(await elsewhere.series(id), isEmpty);
|
||||
await expectLater(
|
||||
elsewhere.save(run, heartRate),
|
||||
throwsA(isA<KeysMissing>()),
|
||||
);
|
||||
expect(rows.written, hasLength(writes));
|
||||
});
|
||||
|
||||
test(
|
||||
"a series the instance moves to another workout doesn't open there",
|
||||
() async {
|
||||
final phone = await device(alice);
|
||||
final first = await phone.save(run, heartRate.take(1).toList());
|
||||
final second = await phone.save(run, heartRate.skip(1).take(1).toList());
|
||||
final moved = rows.seriesRows.firstWhere((row) => row.workoutId == first);
|
||||
rows.seriesRows
|
||||
..remove(moved)
|
||||
..add((id: moved.id, workoutId: second, envelope: moved.envelope));
|
||||
|
||||
expect(await phone.series(first), isEmpty);
|
||||
expect(await phone.series(second), [heartRate[1]]);
|
||||
},
|
||||
);
|
||||
|
||||
test('a workout the instance made up, readable, or one it changed, is '
|
||||
"locked, never the person's", () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate);
|
||||
final sealed = rows.workoutRows[id]!;
|
||||
final changed = Uint8List.fromList(Uint8List.sublistView(sealed.payload));
|
||||
changed[changed.length - 1] ^= 1;
|
||||
rows.workoutRows[id] = (
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: ByteData.sublistView(changed),
|
||||
);
|
||||
rows.workoutRows['made-up'] = (
|
||||
keyId: null,
|
||||
nonce: null,
|
||||
payload: ByteData.sublistView(
|
||||
Uint8List.fromList(
|
||||
utf8.encode(
|
||||
'{"schemaVersion":1,"activity":"running","startedAt":0,'
|
||||
'"endedAt":0}',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(await phone.all(), [
|
||||
StoredWorkout(id, null),
|
||||
const StoredWorkout('made-up', null),
|
||||
]);
|
||||
});
|
||||
|
||||
test('deleting a workout deletes its series', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.save(run, heartRate);
|
||||
final kept = await phone.save(run, heartRate.take(1).toList());
|
||||
|
||||
await phone.delete(id);
|
||||
|
||||
expect(await phone.all(), [StoredWorkout(kept, run)]);
|
||||
expect(rows.seriesRows.map((row) => row.workoutId), [kept]);
|
||||
});
|
||||
|
||||
test('changes from other devices are announced', () async {
|
||||
final phone = await device(alice);
|
||||
final announced = expectLater(phone.changes, emits(null));
|
||||
|
||||
rows.bringRemoteChanges();
|
||||
|
||||
await announced;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_gateway.dart';
|
||||
|
||||
/// [WorkoutGateway] in memory, whose rows a test changes as an instance
|
||||
/// could; it records the envelopes written, so a test sees what reached it.
|
||||
/// Blanking before a delete is the sync gateway's.
|
||||
class MemoryWorkoutGateway() implements WorkoutGateway {
|
||||
final workoutRows = <String, Envelope>{};
|
||||
final seriesRows = <SeriesRow>[];
|
||||
|
||||
/// Every envelope ever written, in order.
|
||||
final written = <Envelope>[];
|
||||
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
/// As a sync bringing others' changes.
|
||||
void bringRemoteChanges() => _remote.add(null);
|
||||
|
||||
@override
|
||||
Future<List<WorkoutRow>> workouts() async => [
|
||||
for (final MapEntry(:key, :value) in workoutRows.entries)
|
||||
(id: key, envelope: value),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<SeriesRow>> seriesOf(String workoutId) async => [
|
||||
for (final row in seriesRows)
|
||||
if (row.workoutId == workoutId) row,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(WorkoutRow workout, List<SeriesRow> series) async {
|
||||
workoutRows[workout.id] = workout.envelope;
|
||||
seriesRows.addAll(series);
|
||||
written
|
||||
..add(workout.envelope)
|
||||
..addAll(series.map((row) => row.envelope));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (workoutRows.containsKey(id)) {
|
||||
workoutRows[id] = envelope;
|
||||
written.add(envelope);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
seriesRows.removeWhere((row) => row.workoutId == id);
|
||||
workoutRows.remove(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remote.stream;
|
||||
}
|
||||
Loading…
Reference in a new issue