diff --git a/app/lib/services/kuchnia_client.dart b/app/lib/services/kuchnia_client.dart new file mode 100644 index 0000000..2f90b8a --- /dev/null +++ b/app/lib/services/kuchnia_client.dart @@ -0,0 +1,192 @@ +/// HTTP access to the Kuchnia Wikinga panel, from the phone. +/// +/// The Dart mirror of `diet_guard/_kuchnia_client.py`. Auth is a cookie +/// session, not a bearer token: `POST auth/login` with a **form-urlencoded** +/// body sets a `SESSION` cookie that every later call carries. +/// +/// `package:http` has no cookie jar, so the `set-cookie` header is read and +/// replayed by hand. `Response.headers` folds duplicate `set-cookie` values +/// into one comma-joined string, which is why the parser below splits and +/// looks for the named cookie rather than assuming the header holds only it. +/// +/// **Android only.** The panel sends no CORS headers, so a browser cannot call +/// it at all; on web every entry point returns an unsupported reason rather +/// than throwing. The `kIsWeb` check comes first, before anything +/// platform-shaped, and this file imports no `dart:io` -- `package:http` +/// compiles for web, it is the *panel* that refuses, not the toolchain. +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/services/kuchnia_errors.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +/// Base URL of the panel's undocumented API. +const kuchniaApiBase = 'https://panel.kuchniavikinga.pl/api'; + +/// Sent as the `company-id` header. The panel wants the company *name*, not a +/// numeric id. +const kuchniaCompany = 'kuchniavikinga'; + +/// The panel's own launcher tag for a browser panel session. +const kuchniaLauncherType = 'BROWSER_PANEL'; + +/// The only cookie the panel issues on login. +/// +/// It sets no `XSRF-TOKEN`, so the CSRF echo its own JavaScript performs is a +/// no-op for this account -- the session cookie alone authenticates. +const kuchniaSessionCookie = 'SESSION'; + +/// Per-request timeout. +const kuchniaTimeout = Duration(seconds: 8); + +/// Whole-walk ceiling. +/// +/// The import is three or four sequential requests, so a per-request timeout +/// alone would permit ~32s against a slow provider -- and this runs on a path +/// the user is waiting on. +const kuchniaTotalDeadline = Duration(seconds: 12); + +/// Why the catering panel cannot be reached from a browser. +const kuchniaWebUnsupportedReason = + 'Android only — the catering panel blocks browser requests'; + +/// Extracts [name]'s value from a (possibly comma-joined) `set-cookie` header. +/// +/// `http`'s `Response.headers` collapses repeated headers into one string, so +/// a panel that sets more than one cookie hands over +/// `SESSION=abc; Path=/, OTHER=def; Path=/`. Returns null when the cookie is +/// absent, which the caller treats as a failed login rather than guessing. +String? sessionCookieFrom(String? setCookieHeader) { + if (setCookieHeader == null || setCookieHeader.isEmpty) return null; + // Split on both separators: ',' joins whole cookies, ';' separates a + // cookie's attributes from its value. + for (final part in setCookieHeader.split(RegExp('[,;]'))) { + final trimmed = part.trim(); + if (trimmed.startsWith('$kuchniaSessionCookie=')) { + final value = trimmed.substring(kuchniaSessionCookie.length + 1); + if (value.isNotEmpty) return value; + } + } + return null; +} + +/// A logged-in panel session with a whole-walk deadline. +class KuchniaSession { + /// Creates a session over [client], authenticating lazily. + KuchniaSession({ + required this.username, + required this.password, + http.Client? client, + DateTime? startedAt, + }) : _client = client ?? http.Client(), + _deadline = + (startedAt ?? DateTime.now()).add(kuchniaTotalDeadline); + + /// The panel e-mail. + final String username; + + /// The panel password. + final String password; + + final http.Client _client; + final DateTime _deadline; + + /// The session cookie: seeded from a cache to skip the login, and read back + /// afterwards so the caller can persist a freshly minted one. + /// + /// Device-local by design -- it is regenerable from the password, so syncing + /// it would widen exposure and buy nothing. + String? sessionCookie; + + Map get _headers => { + 'company-id': kuchniaCompany, + 'X-Launcher-Type': kuchniaLauncherType, + 'User-Agent': 'diet_guard/1.0 (personal diet tracker)', + 'Accept': 'application/json', + if (sessionCookie != null) 'Cookie': '$kuchniaSessionCookie=$sessionCookie', + }; + + void _checkDeadline() { + if (!DateTime.now().isBefore(_deadline)) { + throw const KuchniaError('catering panel too slow (deadline exceeded)'); + } + } + + /// Authenticates and caches the session cookie. + /// + /// No client-wide `Content-Type`: the login body is form-urlencoded and a + /// sticky JSON default would mislabel it. Passing a `Map` to `http.post` + /// sets the right one automatically. + Future login() async { + _checkDeadline(); + final http.Response response; + try { + response = await _client + .post( + Uri.parse('$kuchniaApiBase/auth/login'), + headers: _headers, + body: {'username': username, 'password': password}, + ) + .timeout(kuchniaTimeout); + } on Exception catch (error) { + throw KuchniaError('catering panel unreachable: $error'); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw KuchniaError( + 'catering login rejected (HTTP ${response.statusCode})', + ); + } + final cookie = sessionCookieFrom(response.headers['set-cookie']); + if (cookie == null) { + throw const KuchniaError('catering login returned no session cookie'); + } + sessionCookie = cookie; + } + + /// GETs [path] and decodes it, re-logging in once if the cookie expired. + /// + /// A cached cookie that has expired is indistinguishable from a good one + /// until it is used, so an auth failure is retried exactly once with fresh + /// credentials. A second failure is real. + Future getJson(String path) async { + if (sessionCookie == null) await login(); + var response = await _get(path); + if (response.statusCode == 401 || response.statusCode == 403) { + sessionCookie = null; + await login(); + response = await _get(path); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw KuchniaError( + 'catering panel returned HTTP ${response.statusCode} for $path', + ); + } + try { + return jsonDecode(response.body); + } on FormatException { + throw KuchniaError('catering panel returned a non-JSON body for $path'); + } + } + + Future _get(String path) async { + _checkDeadline(); + try { + return await _client + .get(Uri.parse('$kuchniaApiBase/$path'), headers: _headers) + .timeout(kuchniaTimeout); + } on Exception catch (error) { + throw KuchniaError('catering panel unreachable: $error'); + } + } + + /// Releases the underlying HTTP client. + void close() => _client.close(); +} + +/// True when this platform can reach the catering panel at all. +/// +/// False on web: the panel sends no CORS headers, so the browser refuses the +/// request before it leaves. Checked before anything platform-shaped runs. +bool get kuchniaFetchSupported => !kIsWeb; diff --git a/app/lib/services/kuchnia_errors.dart b/app/lib/services/kuchnia_errors.dart new file mode 100644 index 0000000..7cfdc68 --- /dev/null +++ b/app/lib/services/kuchnia_errors.dart @@ -0,0 +1,19 @@ +/// The catering import's single error type. +/// +/// Mirrors `diet_guard/_kuchnia_errors.py`. Every failure the panel can hand +/// back becomes one of these, so the fail-closed boundary in +/// `kuchnia_import.dart` has exactly one exception to translate into a reason +/// string -- a catering outage must never break the caller. +library; + +/// Raised when the catering panel cannot be read. +class KuchniaError implements Exception { + /// Creates an error carrying a user-facing [message]. + const KuchniaError(this.message); + + /// Why the panel could not be read, phrased for display. + final String message; + + @override + String toString() => message; +} diff --git a/app/lib/services/kuchnia_import.dart b/app/lib/services/kuchnia_import.dart new file mode 100644 index 0000000..df2beff --- /dev/null +++ b/app/lib/services/kuchnia_import.dart @@ -0,0 +1,134 @@ +/// Fetches the day's catering dishes and banks them. +/// +/// The Dart mirror of `diet_guard/_kuchnia_import.py`, and the module's single +/// fail-closed boundary: every [KuchniaError] is turned into a reason string +/// here, so no caller can be broken by a catering outage. +/// +/// **Nothing is ever logged from here.** A delivered meal is not an eaten +/// meal: dishes land in the *curated* bank and the user still taps to log +/// each one. If the import logged automatically, the gate would satisfy its +/// own checkpoint from a delivery note, and the log would record what the +/// courier dropped off rather than what was eaten -- skipped, shared and +/// binned meals included. +/// +/// Imports go **only** to the curated bank. The derived bank is rebuilt from +/// the log on every meal, so anything written there would vanish at the next +/// entry. +/// +/// The idempotency guard is load-bearing and easy to get wrong. +/// `addManualEntry` restamps `editedAt` unconditionally and the sync merge +/// derives each record's clock from it -- so calling it for unchanged dishes +/// would republish the *entire* curated bank to every peer on every refresh. +/// [bankDishes] therefore compares the nutritional fields first and writes +/// only what actually differs. +library; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/kuchnia_client.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; +import 'package:diet_guard_app/services/kuchnia_errors.dart'; +import 'package:diet_guard_app/services/kuchnia_orders.dart'; + +/// The result of one refresh: the dishes, or why there are none. +class KuchniaRefresh { + /// Creates a refresh result. + const KuchniaRefresh({this.dishes = const [], this.reason}); + + /// The day's dishes, empty on failure or when nothing was delivered. + final List dishes; + + /// Why the panel could not be read, or null on success. + /// + /// Callers surface this and carry on; it is never thrown. + final String? reason; + + /// True when the fetch succeeded, whether or not anything was delivered. + bool get ok => reason == null; +} + +/// Returns the curated-bank record for [dish]. +FoodBankRecord dishToRecord(KuchniaDish dish) => FoodBankRecord( + desc: dish.name, + kcal: dish.kcal, + proteinG: dish.proteinG, + carbsG: dish.carbsG, + fatG: dish.fatG, + grams: dish.grams, + // The bank's count ranks foods by how often they were *eaten*, and a + // delivered dish has not been eaten yet. + count: 0, +); + +/// True when [existing] already says exactly what [dish] says. +/// +/// Compares the nutritional fields only. `editedAt` is excluded deliberately: +/// it is the edit stamp, not part of the record's meaning, and including it +/// would make every comparison differ -- which is the republish flood this +/// guard exists to prevent. +/// +/// Numeric comparison, not string: `270 == 270.0` must be true here exactly as +/// it is in Python's `_matches`. +bool bankRecordMatches(FoodBankRecord existing, KuchniaDish dish) => + existing.desc == dish.name && + existing.kcal == dish.kcal && + existing.proteinG == dish.proteinG && + existing.carbsG == dish.carbsG && + existing.fatG == dish.fatG && + existing.grams == dish.grams; + +/// Adds [dishes] to the curated bank, skipping ones already banked unchanged. +/// +/// Returns how many records were actually written -- which is what the tests +/// assert on, because "the entry exists" passes while the guard misbehaves. +Future bankDishes(List dishes) async { + final bank = await FoodBankService.instance.readManualBank(); + var written = 0; + for (final dish in dishes) { + final existing = bank[dish.bankKey]; + if (existing != null && bankRecordMatches(existing, dish)) continue; + await FoodBankService.instance.addManualEntry(dishToRecord(dish)); + written++; + } + return written; +} + +/// Fetches [day]'s dishes and banks them, never throwing. +/// +/// Returns a reason string instead of raising, so a catering outage cannot +/// break the caller. On web it returns [kuchniaWebUnsupportedReason] without +/// touching the network: the panel sends no CORS headers, so the browser would +/// refuse the request anyway. That check comes first, before anything +/// platform-shaped runs. +Future refreshDelivery( + DateTime day, { + KuchniaSession Function(String username, String password)? sessionFactory, +}) async { + if (!kuchniaFetchSupported) { + return const KuchniaRefresh(reason: kuchniaWebUnsupportedReason); + } + if (!KuchniaCredentialService.isConfigured) { + return const KuchniaRefresh( + reason: 'no catering credentials — add them in Settings', + ); + } + + final session = + (sessionFactory ?? + (username, password) => + KuchniaSession(username: username, password: password))( + KuchniaCredentialService.username, + KuchniaCredentialService.password, + ); + List dishes; + try { + dishes = await fetchDishes(session, day); + } on KuchniaError catch (error) { + return KuchniaRefresh(reason: error.message); + } finally { + session.close(); + } + await bankDishes(dishes); + return KuchniaRefresh(dishes: dishes); +} diff --git a/app/lib/services/kuchnia_orders.dart b/app/lib/services/kuchnia_orders.dart new file mode 100644 index 0000000..551e893 --- /dev/null +++ b/app/lib/services/kuchnia_orders.dart @@ -0,0 +1,80 @@ +/// Walks the panel's endpoints to a day's delivered dishes. +/// +/// The Dart mirror of `diet_guard/_kuchnia_orders.py`. Three calls, and the +/// shape of the walk is not what the endpoint names suggest: +/// +/// 1. `company/customer/order/active-ids` -> `[orderId]`. +/// 2. `company/customer/order/{orderId}` -> the order, which **embeds every +/// delivery** with its date. No enumeration call is needed, but the +/// embedded `deliveryMeals` carry ids only -- no names, no macros. +/// 3. `company/general/menus/delivery/{deliveryId}/new` -> the day's actual +/// menu, with names and per-portion nutrition. +/// +/// Step 3 is the important correction: the obvious-looking +/// `.../deliveries/{id}/details` **404s** (and 400s when keyed by date), +/// confirmed against three separate delivery days. The menu endpoint is keyed +/// by the opaque `deliveryId`, never by the date. +library; + +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/services/kuchnia_client.dart'; +import 'package:diet_guard_app/services/kuchnia_errors.dart'; +import 'package:diet_guard_app/services/kuchnia_parse.dart'; + +/// Returns the first active order id, or null when there is no active order. +Object? activeOrderId(Object? payload) { + if (payload is! List || payload.isEmpty) return null; + return payload.first; +} + +/// Returns the delivery id whose date is [wanted] (an ISO `YYYY-MM-DD`). +/// +/// Null when that day has no delivery, or only a cancelled one -- a `deleted` +/// delivery still appears in the list. +Object? deliveryIdFor(Object? payload, String wanted) { + if (payload is! Map) return null; + final deliveries = payload['deliveries']; + if (deliveries is! List) return null; + for (final delivery in deliveries) { + if (delivery is! Map) continue; + if (delivery['deleted'] == true) continue; + if ('${delivery['date']}' == wanted) return delivery['deliveryId']; + } + return null; +} + +/// Formats [day] as the ISO `YYYY-MM-DD` the panel keys deliveries by. +String isoDay(DateTime day) => + '${day.year.toString().padLeft(4, '0')}-' + '${day.month.toString().padLeft(2, '0')}-' + '${day.day.toString().padLeft(2, '0')}'; + +/// Returns the dishes delivered on [day], empty when nothing is delivered. +/// +/// Throws [KuchniaError] when the panel cannot be read, or when a delivery +/// exists but nothing survived parsing -- either the menu is not published yet +/// or its units failed the consistency check. Both are "no data", but they are +/// worth distinguishing from "no delivery" in the caller's message. +Future> fetchDishes( + KuchniaSession session, + DateTime day, +) async { + final orderId = activeOrderId(await session.getJson( + 'company/customer/order/active-ids', + )); + if (orderId == null) return const []; + + final order = await session.getJson('company/customer/order/$orderId'); + final wanted = isoDay(day); + final deliveryId = deliveryIdFor(order, wanted); + if (deliveryId == null) return const []; + + final menu = await session.getJson( + 'company/general/menus/delivery/$deliveryId/new', + ); + final dishes = parseMenu(menu); + if (dishes.isEmpty) { + throw KuchniaError('catering menu for $wanted has no usable dishes'); + } + return dishes; +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 6325bfb..3d2c367 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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.61+1 +version: 1.0.62+1 environment: sdk: ^3.12.2 diff --git a/app/test/services/kuchnia_bank_guard_test.dart b/app/test/services/kuchnia_bank_guard_test.dart new file mode 100644 index 0000000..b831814 --- /dev/null +++ b/app/test/services/kuchnia_bank_guard_test.dart @@ -0,0 +1,90 @@ +/// The catering bank's idempotency guard. +/// +/// Split out of `kuchnia_import_test.dart` for the repo's 250-line cap. +/// +/// `addManualEntry` restamps `editedAt` unconditionally and the sync merge +/// derives each record's clock from it, so re-banking a dish that has not +/// changed republishes the **whole** curated bank to every peer on every +/// refresh. These assertions are on the **write count** for that reason: +/// "the entry exists" passes happily while the guard is broken. +library; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/kuchnia_import.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// In-memory store, so these tests need no plugin channel. +class _MemoryStore implements DocumentStore { + final Map documents = {}; + + @override + Future read(String name) async => documents[name]; + + @override + Future write(String name, String contents) async { + documents[name] = contents; + } +} + +const _dish = KuchniaDish( + name: 'Kaszotto', + kcal: 400, + proteinG: 25, + carbsG: 45, + fatG: 12, + grams: 300, + priority: 1, + slotLabel: 'Obiad', +); + +void main() { + setUp(() => FoodBankService.resetForTesting(store: _MemoryStore())); + tearDown(FoodBankService.resetForTesting); + + group('the idempotency guard', () { + test('re-banking an unchanged dish writes nothing', () async { + // `addManualEntry` restamps `editedAt` unconditionally, so a second write + // republishes the whole curated bank to every peer. + expect(await bankDishes([_dish]), 1); + expect( + await bankDishes([_dish]), + 0, + reason: 'an unchanged dish was re-banked, which restamps every record ' + 'and republishes the whole curated bank', + ); + }); + + test('a changed macro is re-banked', () async { + expect(await bankDishes([_dish]), 1); + const changed = KuchniaDish( + name: 'Kaszotto', + kcal: 450, + proteinG: 25, + carbsG: 45, + fatG: 12, + grams: 300, + priority: 1, + slotLabel: 'Obiad', + ); + expect(await bankDishes([changed]), 1); + }); + + test('an int-vs-double macro counts as unchanged', () async { + // Python's `_matches` is lenient here (`270 == 270.0`), and a string + // comparison instead would rewrite the bank on every refresh. + const existing = FoodBankRecord( + desc: 'Kaszotto', + kcal: 400, + proteinG: 25, + carbsG: 45, + fatG: 12, + grams: 300, + count: 0, + ); + expect(bankRecordMatches(existing, _dish), isTrue); + }); + }); +} diff --git a/app/test/services/kuchnia_import_test.dart b/app/test/services/kuchnia_import_test.dart new file mode 100644 index 0000000..be5eb36 --- /dev/null +++ b/app/test/services/kuchnia_import_test.dart @@ -0,0 +1,233 @@ +/// The phone's catering fetch, banking and its guards. +/// +/// Three behaviours here are load-bearing, and each fails silently if broken: +/// +/// * **Nothing is ever logged.** A delivered meal is not an eaten meal; if the +/// import logged, the gate would satisfy its own checkpoint from a delivery +/// note. +/// * **Unchanged dishes are not re-banked.** `addManualEntry` restamps +/// `editedAt` unconditionally and the merge derives each record's clock from +/// it, so re-banking republishes the whole curated bank to every peer. The +/// assertions are on the **write count** -- "the entry exists" passes while +/// this misbehaves. +/// * **A failure is a reason string, never a throw**, so a catering outage +/// cannot break the caller. +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/models/kuchnia_dish.dart'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/kuchnia_client.dart'; +import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; +import 'package:diet_guard_app/services/kuchnia_import.dart'; +import 'package:diet_guard_app/services/kuchnia_orders.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// In-memory store, so these tests need no plugin channel. +class _MemoryStore implements DocumentStore { + final Map documents = {}; + + @override + Future read(String name) async => documents[name]; + + @override + Future write(String name, String contents) async { + documents[name] = contents; + } +} + +const _menuPath = 'company/general/menus/delivery/DEL1/new'; + +Map _meal(String name, int priority) => { + 'mealName': 'Obiad', + 'menuMealName': name, + 'mealPriority': priority, + 'nutrition': { + 'weight': 300.0, + 'calories': 400.0, + 'protein': 25.0, + 'carbohydrate': 45.0, + 'fat': 12.0, + }, +}; + +/// A panel that logs in and serves a one-dish delivery on 2026-08-23. +MockClient _panel({ + List>? meals, + List? requested, + int loginStatus = 200, +}) => MockClient((request) async { + requested?.add(request.url.path); + if (request.url.path.endsWith('/auth/login')) { + return http.Response( + '', + loginStatus, + headers: {'set-cookie': 'SESSION=abc123; Path=/; HttpOnly'}, + ); + } + if (request.url.path.endsWith('/order/active-ids')) { + return http.Response(jsonEncode(['ORD1']), 200); + } + if (request.url.path.endsWith('/order/ORD1')) { + return http.Response( + jsonEncode({ + 'deliveries': [ + {'date': '2026-08-23', 'deliveryId': 'DEL1'}, + ], + }), + 200, + ); + } + if (request.url.path.endsWith(_menuPath.split('/').last) || + request.url.path.contains('menus/delivery/DEL1')) { + return http.Response( + jsonEncode({'deliveryMenuMeal': meals ?? [_meal('Kaszotto', 1)]}), + 200, + ); + } + return http.Response('', 404); +}); + +void main() { + late _MemoryStore store; + final day = DateTime(2026, 8, 23); + + KuchniaSession Function(String, String) factoryFor(http.Client client) => + (username, password) => KuchniaSession( + username: username, + password: password, + client: client, + ); + + setUp(() async { + store = _MemoryStore(); + LogStorageService.resetForTesting(store: store); + FoodBankService.resetForTesting(store: store); + KuchniaCredentialService.resetForTesting(store: store); + await KuchniaCredentialService.instance.save('me@example.com', 'pw'); + }); + + tearDown(() { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + KuchniaCredentialService.resetForTesting(); + }); + + group('the walk', () { + test('banks the day\'s dishes and logs nothing', () async { + final result = await refreshDelivery( + day, + sessionFactory: factoryFor(_panel()), + ); + + expect(result.ok, isTrue); + expect(result.dishes.single.name, 'Kaszotto'); + final bank = await FoodBankService.instance.readManualBank(); + expect(bank.keys, contains('kaszotto')); + // The whole point: a delivered meal is not an eaten meal. + expect(await LogStorageService.instance.todayEntries(), isEmpty); + }); + + test('banks a dish with count 0, not 1', () async { + // The bank's count ranks foods by how often they were *eaten*. + await refreshDelivery(day, sessionFactory: factoryFor(_panel())); + final bank = await FoodBankService.instance.readManualBank(); + expect(bank['kaszotto']!.count, 0); + }); + + test('a day with no delivery is empty, not an error', () async { + final client = MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + return http.Response( + '', + 200, + headers: {'set-cookie': 'SESSION=abc; Path=/'}, + ); + } + if (request.url.path.endsWith('/order/active-ids')) { + return http.Response(jsonEncode(['ORD1']), 200); + } + return http.Response(jsonEncode({'deliveries': []}), 200); + }); + final result = await refreshDelivery( + day, + sessionFactory: factoryFor(client), + ); + expect(result.ok, isTrue); + expect(result.dishes, isEmpty); + }); + + test('no active order is empty, not an error', () async { + final client = MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + return http.Response( + '', + 200, + headers: {'set-cookie': 'SESSION=abc; Path=/'}, + ); + } + return http.Response(jsonEncode([]), 200); + }); + final result = await refreshDelivery( + day, + sessionFactory: factoryFor(client), + ); + expect(result.ok, isTrue); + expect(result.dishes, isEmpty); + }); + }); + + group('failures become reasons, never throws', () { + test('a rejected login', () async { + final result = await refreshDelivery( + day, + sessionFactory: factoryFor(_panel(loginStatus: 401)), + ); + expect(result.ok, isFalse); + expect(result.reason, contains('rejected')); + }); + + test('a delivery whose dishes all fail the unit check', () async { + // Macros quoted per 100 g against a per-portion kcal. + final result = await refreshDelivery( + day, + sessionFactory: factoryFor( + _panel( + meals: [ + { + 'mealName': 'Obiad', + 'menuMealName': 'Bad units', + 'mealPriority': 1, + 'nutrition': { + 'weight': 400.0, + 'calories': 900.0, + 'protein': 5.0, + 'carbohydrate': 6.0, + 'fat': 2.0, + }, + }, + ], + ), + ), + ); + expect(result.ok, isFalse); + expect(result.reason, contains('no usable dishes')); + }); + + test('missing credentials', () async { + KuchniaCredentialService.resetForTesting(store: _MemoryStore()); + final result = await refreshDelivery( + day, + sessionFactory: factoryFor(_panel()), + ); + expect(result.ok, isFalse); + expect(result.reason, contains('no catering credentials')); + }); + }); +} diff --git a/app/test/services/kuchnia_orders_test.dart b/app/test/services/kuchnia_orders_test.dart new file mode 100644 index 0000000..6eb0d8e --- /dev/null +++ b/app/test/services/kuchnia_orders_test.dart @@ -0,0 +1,64 @@ +/// The catering walk's pure helpers: order/delivery selection and the cookie. +/// +/// Split out of `kuchnia_import_test.dart` for the repo's 250-line cap. These +/// need no store, no credentials and no HTTP plumbing -- they are the parts of +/// the walk that are just parsing, and each one encodes a trap recovered from +/// the live panel rather than guessed from its JavaScript. +library; + +import 'package:diet_guard_app/services/kuchnia_client.dart'; +import 'package:diet_guard_app/services/kuchnia_orders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('the order walk', () { + test('skips a cancelled delivery', () { + // A `deleted` delivery still appears in the list. + final order = { + 'deliveries': [ + {'date': '2026-08-23', 'deliveryId': 'GONE', 'deleted': true}, + {'date': '2026-08-23', 'deliveryId': 'REAL'}, + ], + }; + expect(deliveryIdFor(order, '2026-08-23'), 'REAL'); + }); + + test('a malformed order yields no delivery', () { + expect(deliveryIdFor('nope', '2026-08-23'), isNull); + expect(deliveryIdFor({'deliveries': 'nope'}, '2026-08-23'), isNull); + expect(deliveryIdFor({}, '2026-08-23'), isNull); + }); + + test('a malformed active-ids payload yields no order', () { + expect(activeOrderId('nope'), isNull); + expect(activeOrderId([]), isNull); + expect(activeOrderId(['ORD1']), 'ORD1'); + }); + + test('formats the day the way the panel keys deliveries', () { + expect(isoDay(DateTime(2026, 8, 3)), '2026-08-03'); + expect(isoDay(DateTime(2026, 12, 31)), '2026-12-31'); + }); + }); + + group('the session cookie', () { + test('is read out of a comma-joined set-cookie header', () { + // `Response.headers` folds duplicate set-cookie values into one string. + expect( + sessionCookieFrom('SESSION=abc123; Path=/, OTHER=def; Path=/'), + 'abc123', + ); + expect( + sessionCookieFrom('OTHER=def; Path=/, SESSION=abc123; HttpOnly'), + 'abc123', + ); + }); + + test('is null when the panel sets no session cookie', () { + expect(sessionCookieFrom(null), isNull); + expect(sessionCookieFrom(''), isNull); + expect(sessionCookieFrom('OTHER=def; Path=/'), isNull); + expect(sessionCookieFrom('SESSION=; Path=/'), isNull); + }); + }); +}