mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 12:07:42 +01:00
feat: let the phone fetch and bank the catering delivery itself
The phone now walks the panel on its own -- login, active order, delivery, menu -- parses the dishes and banks them, so the feature works with the PC switched off. Read-only: nothing here logs a meal. `package:http` has no cookie jar, so the `SESSION` cookie is read off the login response and replayed by hand. `Response.headers` folds duplicate `set-cookie` values into one comma-joined string, so the parser splits on both `,` and `;` and looks for the named cookie rather than assuming the header holds only it -- tested against both orderings and against a panel that sets no session cookie at all. Android-only, as decided: the panel sends no CORS headers, so a browser cannot call it. `refreshDelivery` checks `kIsWeb` first and returns the unsupported reason without touching the network. No `dart:io` anywhere -- `package:http` compiles for web, it is the panel that refuses, not the toolchain, so this needs no `_io`/`_web` seam. The idempotency guard is ported with its teeth: `addManualEntry` restamps `editedAt` unconditionally, so re-banking an unchanged dish republishes the whole curated bank to every peer. `bankDishes` compares the nutritional fields first and returns a write *count*, which is what the tests assert on -- "the entry exists" passes while this misbehaves. The comparison is numeric, not string, so `270 == 270.0` is a match exactly as in Python's `_matches`. Every failure becomes a reason string, never a throw, so a catering outage cannot break the caller: a rejected login, a delivery whose dishes all fail the unit check, and missing credentials each have a test. One test bug worth noting: the assertion that nothing is logged was missing an `await` and so was comparing a Future against `isEmpty` -- the single most important guarantee in this file was passing vacuously until the split forced it to run. Test files split three ways for the 250-line cap; block counts checked against the pre-split file (16 either side), not just a green suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
This commit is contained in:
parent
8905d072d0
commit
3d82ddefd4
192
app/lib/services/kuchnia_client.dart
Normal file
192
app/lib/services/kuchnia_client.dart
Normal file
@ -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<String, String> 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<void> 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<Object?> 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<http.Response> _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;
|
||||
19
app/lib/services/kuchnia_errors.dart
Normal file
19
app/lib/services/kuchnia_errors.dart
Normal file
@ -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;
|
||||
}
|
||||
134
app/lib/services/kuchnia_import.dart
Normal file
134
app/lib/services/kuchnia_import.dart
Normal file
@ -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<KuchniaDish> 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<int> bankDishes(List<KuchniaDish> 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<KuchniaRefresh> 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<KuchniaDish> 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);
|
||||
}
|
||||
80
app/lib/services/kuchnia_orders.dart
Normal file
80
app/lib/services/kuchnia_orders.dart
Normal file
@ -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<List<KuchniaDish>> 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
90
app/test/services/kuchnia_bank_guard_test.dart
Normal file
90
app/test/services/kuchnia_bank_guard_test.dart
Normal file
@ -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<String, String> documents = {};
|
||||
|
||||
@override
|
||||
Future<String?> read(String name) async => documents[name];
|
||||
|
||||
@override
|
||||
Future<void> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
233
app/test/services/kuchnia_import_test.dart
Normal file
233
app/test/services/kuchnia_import_test.dart
Normal file
@ -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<String, String> documents = {};
|
||||
|
||||
@override
|
||||
Future<String?> read(String name) async => documents[name];
|
||||
|
||||
@override
|
||||
Future<void> write(String name, String contents) async {
|
||||
documents[name] = contents;
|
||||
}
|
||||
}
|
||||
|
||||
const _menuPath = 'company/general/menus/delivery/DEL1/new';
|
||||
|
||||
Map<String, dynamic> _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<Map<String, dynamic>>? meals,
|
||||
List<String>? 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(<Object>[]), 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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
64
app/test/services/kuchnia_orders_test.dart
Normal file
64
app/test/services/kuchnia_orders_test.dart
Normal file
@ -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(<Object>[]), 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user