Merge branch 'train-days' into integration
# Conflicts: # apps/train/halkyon_train_flutter/lib/src/app/train_paths.dart # docs/adr/0030-halkyon-train.md
This commit is contained in:
commit
81adeb91cc
2 changed files with 25705 additions and 410 deletions
|
|
@ -0,0 +1,151 @@
|
|||
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/weight/data/weight_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_weight_gateway.dart';
|
||||
|
||||
/// The person's weights, sealed on the way to the instance and opened on
|
||||
/// the way back (ADR 0030, D7, D64), with libsodium.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemoryWeightGateway rows;
|
||||
final opened = <TrainKeys>[];
|
||||
|
||||
final monday = WeightContent(
|
||||
at: DateTime.utc(2026, 11, 2, 7),
|
||||
kilograms: 74.3,
|
||||
);
|
||||
final tuesday = WeightContent(
|
||||
at: DateTime.utc(2026, 11, 3, 7),
|
||||
kilograms: 74.05,
|
||||
source: WeightSource.healthConnect,
|
||||
healthId: 'hc-1',
|
||||
app: 'com.example.scale',
|
||||
);
|
||||
|
||||
Future<WeightRepository> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return WeightRepository(rows, keys);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemoryWeightGateway();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
test('weights entered on one device read on another of the account, the '
|
||||
'earliest first, and are deleted there', () async {
|
||||
final phone = await device(alice);
|
||||
final second = await phone.save(tuesday);
|
||||
final first = await phone.save(monday);
|
||||
|
||||
final laptop = await device(alice);
|
||||
expect(await laptop.all(), [
|
||||
StoredWeight(first, monday),
|
||||
StoredWeight(second, tuesday),
|
||||
]);
|
||||
|
||||
await laptop.delete(first);
|
||||
expect(await phone.all(), [StoredWeight(second, tuesday)]);
|
||||
});
|
||||
|
||||
test('nothing the instance gets is readable: no weight, no JSON', () async {
|
||||
await (await device(alice)).save(tuesday);
|
||||
|
||||
final sealed = rows.rows.values.single;
|
||||
expect(sealed.keyId, isNotNull);
|
||||
final text = utf8.decode(
|
||||
Uint8List.sublistView(sealed.payload),
|
||||
allowMalformed: true,
|
||||
);
|
||||
expect(text, isNot(contains('74050')));
|
||||
expect(text, isNot(contains('scale')));
|
||||
});
|
||||
|
||||
test("another person's weight, a readable one, or one moved to another "
|
||||
'row shows locked, never as theirs', () async {
|
||||
final id = await (await device(alice)).save(monday);
|
||||
rows.rows
|
||||
..['moved'] = rows.rows[id]!
|
||||
..['readable'] = (
|
||||
keyId: null,
|
||||
nonce: null,
|
||||
payload: encodeWeight(monday),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await (await device(bob)).all()).where((w) => w.content != null),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
{for (final w in await (await device(alice)).all()) w.id: w.content},
|
||||
{id: monday, 'moved': null, 'readable': null},
|
||||
);
|
||||
});
|
||||
|
||||
test('a device without the keys reads nothing and saves nothing', () async {
|
||||
await (await device(alice)).save(monday);
|
||||
final stranger = await device(null);
|
||||
|
||||
expect((await stranger.all()).single.content, isNull);
|
||||
await expectLater(stranger.save(monday), throwsA(isA<KeysMissing>()));
|
||||
});
|
||||
|
||||
group('the payload', () {
|
||||
test('keeps the weight to the gram, its time in UTC, and where it came '
|
||||
'from', () {
|
||||
expect(decodeWeight(encodeWeight(tuesday)), tuesday);
|
||||
expect(decodeWeight(encodeWeight(monday)), monday);
|
||||
final local = WeightContent(
|
||||
at: DateTime(2026, 11, 3, 8),
|
||||
kilograms: 80.0004,
|
||||
);
|
||||
expect(
|
||||
decodeWeight(encodeWeight(local)),
|
||||
WeightContent(at: local.at.toUtc(), kilograms: 80),
|
||||
);
|
||||
});
|
||||
|
||||
test('one of a newer schema, or none at all, reads as nothing', () {
|
||||
ByteData of(String json) =>
|
||||
ByteData.sublistView(Uint8List.fromList(utf8.encode(json)));
|
||||
expect(
|
||||
decodeWeight(
|
||||
of('{"schemaVersion":2,"at":"2026-11-03T07:00:00Z","grams":1}'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(decodeWeight(of('{"schemaVersion":1}')), isNull);
|
||||
expect(
|
||||
decodeWeight(
|
||||
of('{"schemaVersion":1,"at":"2026-11-03T07:00:00Z","grams":0}'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/bloc/weights_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_profile_gateway.dart';
|
||||
import '../../support/memory_weight_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late TrainKeys keys;
|
||||
late WeightRepository weights;
|
||||
late ProfileRepository profile;
|
||||
late WeightsCubit cubit;
|
||||
|
||||
setUp(() async {
|
||||
keys = TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
weights = WeightRepository(MemoryWeightGateway(), keys);
|
||||
profile = ProfileRepository(MemoryProfileGateway(), keys, person: 'alice');
|
||||
await profile.save(const ProfileContent(heightCm: 180, weightKg: 76));
|
||||
cubit = WeightsCubit(weights, profile);
|
||||
await cubit.open();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cubit.close();
|
||||
await weights.dispose();
|
||||
await profile.dispose();
|
||||
keys.dispose();
|
||||
});
|
||||
|
||||
test('a weight entered shows, the earliest first, and the latest sets '
|
||||
"Your body's weight, the other figures kept", () async {
|
||||
final monday = DateTime.utc(2026, 11, 2, 7);
|
||||
await cubit.add(75.2, monday.add(const Duration(days: 1)));
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
await profile.read(),
|
||||
const ProfileContent(heightCm: 180, weightKg: 75.2),
|
||||
);
|
||||
|
||||
// An earlier one, entered late, changes nothing of the profile.
|
||||
await cubit.add(75.8, monday);
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
[for (final weight in cubit.state.weights) weight.content!.kilograms],
|
||||
[75.8, 75.2],
|
||||
);
|
||||
expect(cubit.state.latest?.kilograms, 75.2);
|
||||
expect((await profile.read())?.weightKg, 75.2);
|
||||
expect(cubit.state.weights.first.content!.source, WeightSource.own);
|
||||
});
|
||||
|
||||
test('a weight deleted goes', () async {
|
||||
await cubit.add(75.2, DateTime.utc(2026, 11, 3, 7));
|
||||
await pumpEventQueue();
|
||||
await cubit.delete(cubit.state.weights.single.id);
|
||||
await pumpEventQueue();
|
||||
expect(cubit.state.weights, isEmpty);
|
||||
expect(cubit.state.loaded, isTrue);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue