feat: carry the catering credential to the phone

The Dart half of the credential sync: a `KuchniaCredentialService` that owns
the value locally, a `sync_merge_kuchnia.dart` adapter mirroring the Python
one, and a `_syncKuchniaCredential` tick alongside the banks and budget. The
phone can now receive the panel password, which is the prerequisite for it
fetching anything itself.

One source of truth, per the answered question: the value lives in the synced
document store and nowhere else. Deliberately *not* mirrored into
`flutter_secure_storage` -- two homes for one value is how a stale password
survives a change made on the other device, and the credential has to be
readable synchronously at merge time anyway.

Gated by a second shared fixture, `tests/fixtures/kuchnia_credential.json`,
asserted by both suites. Two cases carry the weight:

- an **unparsable** edit time must fall back to the same clock on both sides
  (Python catches `ValueError` from `fromisoformat`, Dart's `tryParse` returns
  null). Verified they agree on `wall_time_ms == 0`; mutating that expectation
  fails both suites.
- a **blank half** contributes nothing, so a half-filled settings form cannot
  push an empty-string password that wins LWW against a real one.

`KuchniaCredentialService.init()` is called in the WorkManager isolate as well
as `main()`. A fresh isolate has its own static state and `_syncKuchniaCredential`
no-ops when the singleton is missing, so without it a background tick would
neither publish this device's credential nor receive a peer's -- and on the
phone the background tick is most of the syncing there is. Same class of trap
as the `initSyncDeviceId()` one already documented there.

`.toDouble()` came off the bank record: the fields are already `double`, so it
was a no-op the analyzer flags. The field *types* are what keep the encoding
byte-identical to Python's, and `kuchnia_parity_test.dart` asserts that.

654 Dart tests, 1040 Python tests, 100% Python branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 08:25:27 +02:00
parent 04f5d75aab
commit 8905d072d0
15 changed files with 881 additions and 16 deletions

View File

@ -9,6 +9,7 @@ import 'package:diet_guard_app/services/background_tasks.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/frame_stats.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/services/notification_service.dart';
@ -26,6 +27,7 @@ Future<void> main() async {
await BudgetHistoryService.init();
await MealScheduleService.init();
await FoodBankService.init();
await KuchniaCredentialService.init();
final notifications = await NotificationService.init();
// Deliberately *not* awaited before the first frame: the browser's
// `Notification.requestPermission()` does not complete until the user

View File

@ -58,17 +58,20 @@ class KuchniaDish {
/// ranks foods by how often they were *eaten*, and a delivered dish has not
/// been eaten yet.
///
/// Every macro is forced to `double` before it reaches `jsonEncode`.
/// Every macro is declared `double` on this class rather than `num`, which
/// is what keeps the encoded record byte-identical to Python's.
/// `jsonEncode(435)` emits `435` where Python emits `435.0`, and a
/// byte-different record for the same dish makes every refresh look like a
/// change -- which republishes the whole curated bank to every peer.
/// change -- which republishes the whole curated bank to every peer. A
/// `.toDouble()` here would be a no-op the analyzer flags; the field types
/// are the guarantee, and `kuchnia_parity_test.dart` asserts the encoding.
Map<String, dynamic> toBankRecord() => {
'desc': name,
'kcal': kcal.toDouble(),
'protein_g': proteinG.toDouble(),
'carbs_g': carbsG.toDouble(),
'fat_g': fatG.toDouble(),
'grams': grams.toDouble(),
'kcal': kcal,
'protein_g': proteinG,
'carbs_g': carbsG,
'fat_g': fatG,
'grams': grams,
'count': 0,
};

View File

@ -11,6 +11,7 @@ import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/firebase_client.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
@ -56,6 +57,11 @@ Future<bool> backgroundSyncPush({http.Client? httpClient}) async {
// to the default four meals and nag for checkpoints the user's actual
// schedule does not have.
await MealScheduleService.init();
// Same fresh-isolate reasoning: `_syncKuchniaCredential` no-ops when this
// singleton is uninitialised, so without this a background tick would
// neither publish this device's catering credential nor receive a peer's --
// and on the phone the background tick is most of the syncing there is.
await KuchniaCredentialService.init();
final SyncSettings settings;
try {
settings = await SyncSettings.load();

View File

@ -0,0 +1,152 @@
/// The catering panel credential, stored locally and synced across devices.
///
/// One source of truth: this service owns the value, sync writes back into it,
/// and the settings screen reads and writes it here. There is no second copy
/// to drift -- notably it is *not* mirrored into `flutter_secure_storage`,
/// because two homes for one value is exactly how a stale password survives a
/// change made on the other device.
///
/// **The password is stored and synced in plaintext.** The rest of the synced
/// state is not encrypted either, so do not describe this as "encrypted like
/// everything else". The user chose it deliberately: the alternative was a
/// phone that cannot fetch the catering menu at all, and the blast radius is a
/// catering menu and a delivery address.
///
/// Mirrors `diet_guard/_kuchnia_credential_store.py`.
library;
import 'dart:convert';
import 'package:diet_guard_app/services/document_store.dart';
import 'package:diet_guard_app/services/document_store_factory.dart';
import 'package:flutter/foundation.dart';
/// Reads and writes the catering panel login.
class KuchniaCredentialService {
KuchniaCredentialService._(this._store);
/// Document name this service owns.
static const documentName = 'kuchnia_credential.json';
static KuchniaCredentialService? _instance;
/// Returns the initialized singleton; throws if [init] was not called.
static KuchniaCredentialService get instance => _instance!;
/// True once [init] has run, so callers can skip work in widget tests that
/// never initialise this service.
static bool get isInitialized => _instance != null;
final DocumentStore _store;
String _username = '';
String _password = '';
String _editedAt = '';
/// The panel e-mail, or the empty string when unset.
static String get username => _instance?._username ?? '';
/// The panel password, or the empty string when unset.
static String get password => _instance?._password ?? '';
/// When the credential was last set, ISO format, or '' when never.
static String get editedAt => _instance?._editedAt ?? '';
/// True when both halves are present, i.e. a fetch could be attempted.
static bool get isConfigured => username.isNotEmpty && password.isNotEmpty;
/// Initialises the singleton against the platform document store.
static Future<KuchniaCredentialService> init() async {
if (_instance != null) return _instance!;
// Resolving the platform store is a plugin call, not reachable from
// `flutter test`.
// coverage:ignore-start
final service = KuchniaCredentialService._(await openDocumentStore());
// coverage:ignore-end
await service._load();
_instance = service;
return service;
}
/// Initialises from [store] for use in unit tests.
@visibleForTesting
static Future<KuchniaCredentialService> initForTesting(
DocumentStore store,
) async {
final service = KuchniaCredentialService._(store);
await service._load();
_instance = service;
return service;
}
/// Resets the singleton so [init] can be called again in tests.
///
/// When [store] is given, reads/writes go there instead of the real platform
/// store -- and because [init] returns early once an instance exists, this
/// is also what keeps a test off the path_provider channel. Mirrors
/// `AppSettingsService.resetForTesting`.
@visibleForTesting
static void resetForTesting({DocumentStore? store}) {
_instance = store == null ? null : KuchniaCredentialService._(store);
}
Future<void> _load() async {
final raw = await _store.read(documentName);
if (raw == null) return;
try {
final data = jsonDecode(raw);
if (data is! Map) return;
_username = data['username'] is String ? data['username'] as String : '';
_password = data['password'] is String ? data['password'] as String : '';
_editedAt = data['t'] is String ? data['t'] as String : '';
} on FormatException {
// Keep the defaults; a corrupt document means "not configured", which is
// never an error at the call site.
}
}
/// Stores a credential the *user* just entered, stamping it now.
///
/// The stamp is what the cross-device merge compares, so an edit made here
/// beats an older one from the PC and vice versa.
Future<void> save(String username, String password) => _persist(
username.trim(),
password,
DateTime.now().toIso8601String(),
);
/// Applies a credential the sync merge resolved, keeping its edit time.
///
/// Persists [editedAt] verbatim -- the winning side's real edit time, not
/// "now" -- so re-syncing an unchanged credential stays idempotent instead
/// of making this device look like the most recent editor on every tick.
Future<void> applySynced(
String username,
String password,
String editedAt,
) async {
if (username == _username &&
password == _password &&
editedAt == _editedAt) {
return;
}
await _persist(username, password, editedAt);
}
Future<void> _persist(
String username,
String password,
String editedAt,
) async {
_username = username;
_password = password;
_editedAt = editedAt;
await _store.write(
documentName,
jsonEncode({
'username': _username,
'password': _password,
't': _editedAt,
}),
);
}
}

View File

@ -57,8 +57,7 @@ List<SlottedDish> assignSlots(List<KuchniaDish> dishes, List<int> slots) {
// agree without relying on either sort's stability.
final indexed = <(int, KuchniaDish)>[
for (var i = 0; i < dishes.length; i++) (i, dishes[i]),
];
indexed.sort((a, b) {
]..sort((a, b) {
final byPriority = a.$2.priority.compareTo(b.$2.priority);
if (byPriority != 0) return byPriority;
final byName = a.$2.name.compareTo(b.$2.name);
@ -81,5 +80,7 @@ List<SlottedDish> assignSlots(List<KuchniaDish> dishes, List<int> slots) {
///
/// So the dish offered for the slot being filled is the one the catering
/// actually intends for it, rather than whatever order the payload arrived in.
List<KuchniaDish> dishesInSlotOrder(List<KuchniaDish> dishes, List<int> slots) =>
[for (final item in assignSlots(dishes, slots)) item.dish];
List<KuchniaDish> dishesInSlotOrder(
List<KuchniaDish> dishes,
List<int> slots,
) => [for (final item in assignSlots(dishes, slots)) item.dish];

View File

@ -0,0 +1,146 @@
/// The catering panel credential as its own synced document.
///
/// The phone fetches the catering menu itself, so it needs the panel password
/// -- and a phone that has been wiped and reinstalled needs it back without
/// the user digging out the original. So the credential syncs.
///
/// **It travels in plaintext.** Nothing here encrypts it and the rest of the
/// synced state is not encrypted either, so do not describe it as "encrypted
/// like everything else". Its blast radius is a catering menu and a delivery
/// address, and the user chose this deliberately over a phone that cannot
/// fetch at all. The live session cookie is *not* synced: it is regenerable
/// from the password, so copying it would widen exposure and buy nothing.
///
/// Its own document rather than a field on the `budget` record: `budget.json`
/// is written back at default permissions on the PC, so a password riding
/// inside it would be readable by anything that reads the budget.
///
/// Two fields rather than one record body, so a device that only ever set the
/// username cannot clobber a peer's password with a whole-map LWW. That is the
/// same reasoning that moved body weight out of the budget's `value` map.
///
/// KEEP IN SYNC WITH `diet_guard/sync_merge/_kuchnia.py`.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
/// Stable id: exactly one credential record per device-pushed file.
const kuchniaRecordId = 'kuchnia';
/// The panel login, as two independently-clocked fields.
///
/// The Python side spells its password constant as `"pass" + "word"` rather
/// than a literal. That is not a different value -- it is the same
/// `'password'` on the wire -- it only stops ruff's S105 reading the literal
/// as a hardcoded secret, in a repo that bans `noqa` outright. Do not
/// "fix" either side to match the other's spelling.
const kuchniaUsernameField = 'username';
/// The password field name on the wire. See [kuchniaUsernameField].
const kuchniaPasswordField = 'password';
/// Derives a deterministic [Hlc] for a credential from its edit time.
///
/// Identical inputs always yield the same clock, so re-syncing an unchanged
/// credential is a no-op rather than a fresh push on every tick.
///
/// Derived from the *parsed* timestamp rather than the raw string, so the two
/// languages agree even though they format the epoch fallback differently --
/// the same trick `sync_merge_schedule.dart` uses. Python catches
/// `ValueError` from `fromisoformat` and falls back to the epoch; the `?? 0`
/// here is that same fallback.
Hlc kuchniaCredentialHlc(String editedAt) {
final wallTimeMs =
DateTime.tryParse(editedAt)?.toUtc().millisecondsSinceEpoch ?? 0;
return Hlc.newTick(currentSyncDeviceId, wallTimeMsOverride: wallTimeMs);
}
/// Converts this device's catering credential into a [Log].
///
/// Returns an empty [Log] when either half is blank, so a half-filled settings
/// form contributes nothing to the merge rather than pushing an empty-string
/// password that would win LWW against a peer's real one.
Log credentialToLog(String username, String password, String editedAt) {
if (username.isEmpty || password.isEmpty) return {};
final hlc = kuchniaCredentialHlc(editedAt);
return {
kuchniaRecordId: Record(
id: kuchniaRecordId,
fields: {
kuchniaUsernameField: (username, hlc),
kuchniaPasswordField: (password, hlc),
},
),
};
}
/// One resolved catering credential, as the merge settled it.
class KuchniaCredential {
/// Creates a resolved credential.
const KuchniaCredential({
required this.username,
required this.password,
required this.editedAt,
});
/// The panel e-mail.
final String username;
/// The panel password.
final String password;
/// When the winning side last set it, ISO format.
final String editedAt;
}
/// Extracts the credential from a merged [Log].
///
/// Returns null when no device has contributed one yet, or when the merged
/// record is missing either half -- callers treat that as "not configured",
/// which is never an error at the call site.
///
/// `editedAt` is reconstructed from the winning field's own Hlc rather than
/// carried separately, so the stored timestamp and the clock the merge
/// compared can never drift apart.
KuchniaCredential? logToCredential(Log log) {
final record = log[kuchniaRecordId];
if (record == null) return null;
final username = record.fields[kuchniaUsernameField]?.$1;
final passwordField = record.fields[kuchniaPasswordField];
final password = passwordField?.$1;
if (username is! String || password is! String) return null;
if (username.isEmpty || password.isEmpty) return null;
final hlc = passwordField!.$2;
return KuchniaCredential(
username: username,
password: password,
editedAt: DateTime.fromMillisecondsSinceEpoch(
hlc.wallTimeMs,
isUtc: true,
).toLocal().toIso8601String(),
);
}
/// Parses one device's pushed credential file into a [Log].
///
/// Throwing [FormatException] or [TypeError] is treated as unparsable by the
/// caller, matching `parseRemoteBudget`'s tolerance for a corrupt push.
Log parseRemoteCredential(String text) {
final raw = jsonDecode(text);
if (raw is! Map) {
throw const FormatException(
'top-level catering credential payload is not a JSON object',
);
}
return raw.cast<String, dynamic>().map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
}
/// Serializes a merged credential [Log] for push.
String encodeCredentialForPush(Log log) => jsonEncode({
for (final entry in log.entries) entry.key: entry.value.toJson(),
});

View File

@ -17,10 +17,12 @@ import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
import 'package:diet_guard_app/services/sync_merge_kuchnia.dart';
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
import 'package:diet_guard_app/services/sync_state_factory.dart';
@ -59,6 +61,7 @@ Future<DayLog> runSync(RemoteStore client, {SyncStateStore? stateStore}) async {
await _syncBudget(client);
await _syncFoodBank(client);
await _syncManualBank(client);
await _syncKuchniaCredential(client);
return merged;
}
@ -172,3 +175,39 @@ Future<void> _syncBudget(RemoteStore client) async {
updatedAt: DateTime.tryParse(merged['t'] as String? ?? ''),
);
}
/// Pulls peers' catering credentials, merges, applies the winner locally.
///
/// The phone runs its own catering importer, so it cannot fetch anything until
/// the panel password reaches it -- that is the whole reason this credential
/// syncs at all. It is plaintext on the wire, like the rest of the synced
/// state; see `sync_merge_kuchnia.dart`.
///
/// A device that has no credential contributes an empty log, so it neither
/// blocks nor clobbers a peer's real value -- it relays it. Mirrors
/// `diet_guard/_sync_kuchnia.py`.
Future<void> _syncKuchniaCredential(RemoteStore client) async {
if (!KuchniaCredentialService.isInitialized) return;
final merged = await syncLog(
client: client,
deviceId: currentSyncDeviceId,
legacyDeviceId: legacySyncDeviceId,
pathPrefix: _devicesDir,
localLog: credentialToLog(
KuchniaCredentialService.username,
KuchniaCredentialService.password,
KuchniaCredentialService.editedAt,
),
encode: encodeCredentialForPush,
decode: parseRemoteCredential,
filename: 'kuchnia.json',
commitMessage: 'diet_guard_app sync',
);
final credential = logToCredential(merged);
if (credential == null) return;
await KuchniaCredentialService.instance.applySynced(
credential.username,
credential.password,
credential.editedAt,
);
}

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.60+1
version: 1.0.61+1
environment:
sdk: ^3.12.2

View File

@ -10,6 +10,7 @@ import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/background_sync_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
@ -36,6 +37,9 @@ void main() {
store: FileDocumentStore(tempDir),
);
MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir));
KuchniaCredentialService.resetForTesting(
store: FileDocumentStore(tempDir),
);
});
tearDown(() async {
@ -44,6 +48,7 @@ void main() {
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
MealScheduleService.resetForTesting();
KuchniaCredentialService.resetForTesting();
await tempDir.delete(recursive: true);
});
@ -85,10 +90,10 @@ void main() {
expect(ok, isTrue);
// syncLog always pushes, even an empty merged result: food_log.json,
// budget.json, food_bank.json, food_bank_manual.json.
// Four data files plus this device's revision, which is what lets a
// budget.json, food_bank.json, food_bank_manual.json, kuchnia.json.
// Five data files plus this device's revision, which is what lets a
// later tick skip an unchanged peer.
expect(puts, 5);
expect(puts, 6);
});
test('pushes under the persisted uuid, not the legacy role id', () async {

View File

@ -12,6 +12,7 @@ import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/due_slot_check.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/services/notification_backend_io.dart';
@ -52,6 +53,9 @@ void main() {
store: FileDocumentStore(tempDir),
);
MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir));
KuchniaCredentialService.resetForTesting(
store: FileDocumentStore(tempDir),
);
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
notificationLog = installFakeAndroidNotifications();
@ -65,6 +69,7 @@ void main() {
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
KuchniaCredentialService.resetForTesting();
MealScheduleService.resetForTesting();
NotificationService.resetForTesting();
await tempDir.delete(recursive: true);

View File

@ -0,0 +1,135 @@
/// The catering credential's local storage.
///
/// The behaviour worth pinning is the split between a *user* edit and a *sync
/// write-back*: the first stamps now (so it can win the LWW race it should
/// win), the second keeps the winning side's own stamp (so re-syncing an
/// unchanged credential stays idempotent instead of making this device look
/// like the most recent editor on every tick).
library;
import 'dart:convert';
import 'package:diet_guard_app/services/document_store.dart';
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
import 'package:flutter_test/flutter_test.dart';
/// In-memory store, so these tests need no plugin channel.
class _MemoryStore implements DocumentStore {
final Map<String, String> documents = {};
int writes = 0;
@override
Future<String?> read(String name) async => documents[name];
@override
Future<void> write(String name, String contents) async {
documents[name] = contents;
writes++;
}
}
void main() {
late _MemoryStore store;
setUp(() {
store = _MemoryStore();
KuchniaCredentialService.resetForTesting();
});
tearDown(KuchniaCredentialService.resetForTesting);
test('reads back nothing before anything is stored', () async {
await KuchniaCredentialService.initForTesting(store);
expect(KuchniaCredentialService.username, isEmpty);
expect(KuchniaCredentialService.password, isEmpty);
expect(KuchniaCredentialService.isConfigured, isFalse);
});
test('a user edit round-trips and stamps a time', () async {
await KuchniaCredentialService.initForTesting(store);
await KuchniaCredentialService.instance.save('me@example.com', 'hunter2');
expect(KuchniaCredentialService.username, 'me@example.com');
expect(KuchniaCredentialService.password, 'hunter2');
expect(KuchniaCredentialService.editedAt, isNotEmpty);
expect(KuchniaCredentialService.isConfigured, isTrue);
});
test('a stored credential survives a reload', () async {
await KuchniaCredentialService.initForTesting(store);
await KuchniaCredentialService.instance.save('me@example.com', 'hunter2');
KuchniaCredentialService.resetForTesting();
await KuchniaCredentialService.initForTesting(store);
expect(KuchniaCredentialService.username, 'me@example.com');
expect(KuchniaCredentialService.password, 'hunter2');
});
test('the username is trimmed but the password is not', () async {
// A pasted e-mail often carries whitespace; a password's whitespace may be
// load-bearing, so trimming it would lock the user out of their own panel.
await KuchniaCredentialService.initForTesting(store);
await KuchniaCredentialService.instance.save(' me@example.com ', ' pw ');
expect(KuchniaCredentialService.username, 'me@example.com');
expect(KuchniaCredentialService.password, ' pw ');
});
test('a sync write-back keeps the winning edit time', () async {
await KuchniaCredentialService.initForTesting(store);
await KuchniaCredentialService.instance.applySynced(
'peer@example.com',
'peer-pass',
'2026-08-20T09:00:00.000',
);
expect(KuchniaCredentialService.editedAt, '2026-08-20T09:00:00.000');
});
test('an unchanged sync write-back does not rewrite the document', () async {
// Otherwise every tick looks like an edit and republishes to every peer.
await KuchniaCredentialService.initForTesting(store);
await KuchniaCredentialService.instance.applySynced(
'peer@example.com',
'peer-pass',
'2026-08-20T09:00:00.000',
);
final writesAfterFirst = store.writes;
await KuchniaCredentialService.instance.applySynced(
'peer@example.com',
'peer-pass',
'2026-08-20T09:00:00.000',
);
expect(store.writes, writesAfterFirst);
});
test('a corrupt document reads back as not configured', () async {
store.documents[KuchniaCredentialService.documentName] = '{not json';
await KuchniaCredentialService.initForTesting(store);
expect(KuchniaCredentialService.isConfigured, isFalse);
});
test('a non-object document reads back as not configured', () async {
store.documents[KuchniaCredentialService.documentName] = '[1, 2]';
await KuchniaCredentialService.initForTesting(store);
expect(KuchniaCredentialService.isConfigured, isFalse);
});
test('wrongly typed halves read back as not configured', () async {
store.documents[KuchniaCredentialService.documentName] = jsonEncode({
'username': 'me@example.com',
'password': 42,
});
await KuchniaCredentialService.initForTesting(store);
expect(KuchniaCredentialService.password, isEmpty);
expect(KuchniaCredentialService.isConfigured, isFalse);
});
test('static getters are safe before init', () {
// Widget tests never initialise this service, and reading it must not
// throw for them.
expect(KuchniaCredentialService.username, isEmpty);
expect(KuchniaCredentialService.isConfigured, isFalse);
expect(KuchniaCredentialService.isInitialized, isFalse);
});
}

View File

@ -0,0 +1,166 @@
/// The Dart half of the catering-credential sync gate.
///
/// `diet_guard/tests/test_kuchnia_credential_sync.py` asserts the same cases
/// against the same `tests/fixtures/kuchnia_credential.json`.
///
/// Two behaviours have to agree across the languages or the merge misbehaves
/// asymmetrically:
///
/// * an **unparsable** edit time must fall back to the same clock (Python
/// catches `ValueError` from `fromisoformat`, Dart's `DateTime.tryParse`
/// returns null), or a junk stamp hands the race to whichever side is more
/// forgiving, and
/// * a **blank half** must contribute nothing, or a half-filled settings form
/// pushes an empty-string password that wins LWW against a real one.
library;
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
import 'package:diet_guard_app/services/sync_merge_kuchnia.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// The repo root, found by walking up from the package directory.
Directory get _repoRoot {
var dir = Directory.current;
while (!File('${dir.path}/pubspec.yaml').existsSync()) {
final parent = dir.parent;
if (parent.path == dir.path) {
fail('could not locate app/ from ${Directory.current.path}');
}
dir = parent;
}
return dir.parent;
}
void main() {
late Map<String, dynamic> fixture;
setUp(() async {
// Pin the node id to the fixture's. It differs per install and is not part
// of the parity claim; the wall time and the field values are.
SharedPreferences.setMockInitialValues({
kSyncDeviceIdKey: fixture['device_id'] as String,
});
resetSyncDeviceIdForTest();
await initSyncDeviceId();
});
setUpAll(() {
final file = File(
'${_repoRoot.path}/tests/fixtures/kuchnia_credential.json',
);
expect(
file.existsSync(),
isTrue,
reason: 'shared credential fixture missing at ${file.path}; regenerate '
'with scripts/build_kuchnia_credential_fixture.py',
);
fixture = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
});
test('every shared case resolves the way Python resolves it', () {
for (final raw in fixture['cases'] as List) {
final testCase = (raw as Map).cast<String, dynamic>();
final name = testCase['name'] as String;
final log = credentialToLog(
testCase['username'] as String,
testCase['password'] as String,
testCase['edited_at'] as String,
);
expect(log.isEmpty, equals(testCase['expected_empty']), reason: name);
if (testCase['expected_empty'] as bool) continue;
final record = log[kuchniaRecordId]!;
expect(
record.fields[kuchniaUsernameField]!.$1,
equals(testCase['expected_username']),
reason: name,
);
expect(
record.fields[kuchniaPasswordField]!.$1,
equals(testCase['expected_password']),
reason: name,
);
expect(
record.fields[kuchniaPasswordField]!.$2.wallTimeMs,
equals(testCase['expected_wall_time_ms']),
reason: '$name: the two languages disagree about this edit time, so a '
'credential would resolve differently per device',
);
}
});
test('round trips through the wire format', () {
final log = credentialToLog('me@example.com', 'hunter2', '2026-08-23T10:00:00Z');
final restored = parseRemoteCredential(encodeCredentialForPush(log));
final credential = logToCredential(restored);
expect(credential, isNotNull);
expect(credential!.username, equals('me@example.com'));
expect(credential.password, equals('hunter2'));
});
test('an empty log reads back as not configured', () {
expect(logToCredential({}), isNull);
});
test('a record missing a half reads back as not configured', () {
final hlc = Hlc.newTick('peer', wallTimeMsOverride: 1700000000000);
final log = {
kuchniaRecordId: Record(
id: kuchniaRecordId,
fields: {kuchniaUsernameField: ('me@example.com', hlc)},
),
};
expect(logToCredential(log), isNull);
});
test('a blank half in a merged record reads back as not configured', () {
final hlc = Hlc.newTick('peer', wallTimeMsOverride: 1700000000000);
final log = {
kuchniaRecordId: Record(
id: kuchniaRecordId,
fields: {
kuchniaUsernameField: ('me@example.com', hlc),
kuchniaPasswordField: ('', hlc),
},
),
};
expect(logToCredential(log), isNull);
});
test('a field from a newer release is relayed, not dropped', () {
// The same canary the Python suite runs. `mergeRecord` is per-field LWW
// over the union of field names and both sides push the merged record, so
// a device predating a field relays it -- which is what makes this
// shippable without a coordinated release.
final hlc = Hlc.newTick('peer', wallTimeMsOverride: 1700000000000);
final peer = {
kuchniaRecordId: Record(
id: kuchniaRecordId,
fields: {
kuchniaUsernameField: ('me@example.com', hlc),
kuchniaPasswordField: ('hunter2', hlc),
'future-field': ('from-a-newer-release', hlc),
},
),
};
final merged = mergeLogs(credentialToLog('', '', ''), peer);
final pushed = jsonDecode(encodeCredentialForPush(merged)) as Map;
final fields = (pushed[kuchniaRecordId] as Map)['fields'] as Map;
expect(
fields.containsKey('future-field'),
isTrue,
reason: 'a field from a newer release was dropped; this device would '
'silently delete it from every peer on every tick',
);
});
test('a non-object payload is rejected', () {
expect(() => parseRemoteCredential('[1, 2]'), throwsFormatException);
});
}

View File

@ -16,6 +16,9 @@ dropped on the floor and never noticed.
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
from crdt_sync import Hlc, Record, merge_logs
import pytest
@ -95,7 +98,7 @@ def test_re_syncing_an_unchanged_credential_is_a_no_op() -> None:
assert encode_credential_for_push(first) == encode_credential_for_push(second)
def test_an_unparseable_edit_time_loses_to_a_real_one() -> None:
def test_an_unparsable_edit_time_loses_to_a_real_one() -> None:
"""A junk timestamp falls back to the epoch rather than winning by accident."""
junk = credential_to_log("junk@example.com", "junk-pass", "not-a-timestamp")
real = credential_to_log("real@example.com", "real-pass", _EDITED)
@ -160,3 +163,46 @@ def test_a_non_object_payload_is_rejected() -> None:
"""A corrupt push raises rather than being half-applied."""
with pytest.raises(TypeError, match="not an object"):
parse_remote_credential("[1, 2]")
def _credential_fixture() -> dict[str, Any]:
"""The shared payload/expected pair, read from the committed JSON."""
path = (
Path(__file__).resolve().parents[2] / "tests/fixtures/kuchnia_credential.json"
)
with path.open(encoding="utf-8") as handle:
data: dict[str, Any] = json.load(handle)
return data
def test_the_shared_credential_fixture_matches() -> None:
"""The Dart adapter asserts these same cases against this same file.
The two that matter: an unparsable edit time must fall back to the same
clock on both sides, and a blank half must contribute nothing. A junk stamp
resolving differently per device would hand the LWW race to whichever side
was more forgiving.
"""
fixture = _credential_fixture()
with patch(
"diet_guard.sync_merge._kuchnia.device_id",
return_value=fixture["device_id"],
):
for case in fixture["cases"]:
log = credential_to_log(
case["username"], case["password"], case["edited_at"]
)
assert (not log) == case["expected_empty"], case["name"]
if case["expected_empty"]:
continue
record = log[KUCHNIA_RECORD_ID]
assert record.fields[USERNAME_FIELD_NAME][0] == case["expected_username"], (
case["name"]
)
assert record.fields[PASSWORD_FIELD_NAME][0] == case["expected_password"], (
case["name"]
)
assert (
record.fields[PASSWORD_FIELD_NAME][1].wall_time_ms
== case["expected_wall_time_ms"]
), case["name"]

View File

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Regenerate the shared catering-credential parity fixture.
The fixture (``tests/fixtures/kuchnia_credential.json``) is read by *both*
``diet_guard/tests/test_kuchnia_credential_sync.py`` and
``app/test/sync_merge_kuchnia_test.dart``, for the same reason the menu
fixture is: two suites written independently from the same prose is not a
gate.
What has to agree across the two languages is narrow but sharp:
* an **unparsable** edit time must fall back to the same clock on both sides
(Python catches ``ValueError`` from ``fromisoformat``; Dart's
``DateTime.tryParse`` returns null), because a junk stamp that resolved
differently per device would hand the race to whichever side was more
forgiving, and
* a **blank half** must contribute nothing, or a half-filled settings form
pushes an empty-string password that wins LWW against a real one.
The node id is pinned because it differs per install and is not part of what
parity guarantees; the wall time and the field values are.
Run it from the repo root:
python3 scripts/build_kuchnia_credential_fixture.py
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
from diet_guard.sync_merge._kuchnia import credential_to_log
FIXTURE = (
Path(__file__).resolve().parent.parent / "tests/fixtures/kuchnia_credential.json"
)
#: Stands in for the per-install uuid, which is not part of the parity claim.
_DEVICE_ID = "fixturedev"
_CASES: list[dict[str, object]] = [
{
"name": "a normal credential",
"username": "me@example.com",
"password": "hunter2",
"edited_at": "2026-08-23T10:00:00+02:00",
},
{
"name": "an unparsable edit time falls back to the epoch",
"username": "me@example.com",
"password": "hunter2",
"edited_at": "not-a-timestamp",
},
{
"name": "a blank password contributes nothing",
"username": "me@example.com",
"password": "",
"edited_at": "2026-08-23T10:00:00+02:00",
},
{
"name": "a blank username contributes nothing",
"username": "",
"password": "hunter2",
"edited_at": "2026-08-23T10:00:00+02:00",
},
]
def build() -> dict[str, object]:
"""Return the fixture: each case with the result Python produces."""
cases = []
with patch(
"diet_guard.sync_merge._kuchnia.device_id",
return_value=_DEVICE_ID,
):
for case in _CASES:
entry = dict(case)
log = credential_to_log(
str(case["username"]),
str(case["password"]),
str(case["edited_at"]),
)
entry["expected_empty"] = not log
if log:
record = log["kuchnia"]
entry["expected_wall_time_ms"] = record.fields["password"][
1
].wall_time_ms
entry["expected_username"] = record.fields["username"][0]
entry["expected_password"] = record.fields["password"][0]
cases.append(entry)
return {
"_comment": (
"Shared parity fixture for the catering credential's sync adapter. "
"Read by BOTH diet_guard/tests/test_kuchnia_credential_sync.py and "
"app/test/sync_merge_kuchnia_test.dart. Regenerate with "
"scripts/build_kuchnia_credential_fixture.py."
),
"device_id": _DEVICE_ID,
"cases": cases,
}
def main() -> None:
"""Write the fixture to disk.
Deliberately silent: ``T201`` bans ``print`` here, and the script's output
is the file itself -- ``git diff`` is how you check what changed.
"""
FIXTURE.parent.mkdir(parents=True, exist_ok=True)
with FIXTURE.open("w", encoding="utf-8") as handle:
json.dump(build(), handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write("\n")
if __name__ == "__main__":
main()

40
tests/fixtures/kuchnia_credential.json vendored Normal file
View File

@ -0,0 +1,40 @@
{
"_comment": "Shared parity fixture for the catering credential's sync adapter. Read by BOTH diet_guard/tests/test_kuchnia_credential_sync.py and app/test/sync_merge_kuchnia_test.dart. Regenerate with scripts/build_kuchnia_credential_fixture.py.",
"cases": [
{
"edited_at": "2026-08-23T10:00:00+02:00",
"expected_empty": false,
"expected_password": "hunter2",
"expected_username": "me@example.com",
"expected_wall_time_ms": 1787472000000,
"name": "a normal credential",
"password": "hunter2",
"username": "me@example.com"
},
{
"edited_at": "not-a-timestamp",
"expected_empty": false,
"expected_password": "hunter2",
"expected_username": "me@example.com",
"expected_wall_time_ms": 0,
"name": "an unparsable edit time falls back to the epoch",
"password": "hunter2",
"username": "me@example.com"
},
{
"edited_at": "2026-08-23T10:00:00+02:00",
"expected_empty": true,
"name": "a blank password contributes nothing",
"password": "",
"username": "me@example.com"
},
{
"edited_at": "2026-08-23T10:00:00+02:00",
"expected_empty": true,
"name": "a blank username contributes nothing",
"password": "hunter2",
"username": ""
}
],
"device_id": "fixturedev"
}