kuchnia: walk every active order, not just the first
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 12:32:17 +02:00
parent 357bdb7e94
commit 11173c7088
14 changed files with 397 additions and 33 deletions

View File

@ -134,8 +134,13 @@ class KuchniaSession {
throw KuchniaError('catering panel unreachable: $error');
}
if (response.statusCode < 200 || response.statusCode >= 300) {
// A 401 is a wrong password, and on the phone the only place to fix it
// is Settings; any other status is the panel's problem, not the user's.
final hint = response.statusCode == 401
? ' — check the password in Settings'
: '';
throw KuchniaError(
'catering login rejected (HTTP ${response.statusCode})',
'catering login rejected (HTTP ${response.statusCode})$hint',
);
}
final cookie = sessionCookieFrom(response.headers['set-cookie']);

View File

@ -3,7 +3,9 @@
/// 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]`.
/// 1. `company/customer/order/active-ids` -> `[orderId, ...]`. More than one
/// while a renewal overlaps the running order, **newest first** -- so the
/// first id is the future order and today's delivery sits in a later one.
/// 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.
@ -21,10 +23,10 @@ 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 every active order id, empty when there is no active order.
List<Object?> activeOrderIds(Object? payload) {
if (payload is! List) return const [];
return payload;
}
/// Returns the delivery id whose date is [wanted] (an ISO `YYYY-MM-DD`).
@ -59,14 +61,18 @@ Future<List<KuchniaDish>> fetchDishes(
KuchniaSession session,
DateTime day,
) async {
final orderId = activeOrderId(await session.getJson(
final orderIds = activeOrderIds(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);
// Never trust the list's order: on 2026-10-10 a renewal starting 10-24
// came first, and reading only it made every day look delivery-free.
Object? deliveryId;
for (final orderId in orderIds) {
final order = await session.getJson('company/customer/order/$orderId');
deliveryId = deliveryIdFor(order, wanted);
if (deliveryId != null) break;
}
if (deliveryId == null) return const [];
final menu = await session.getJson(

View File

@ -139,7 +139,9 @@ class KuchniaQueueService {
if (service == null) return const KuchniaRefresh();
if (service.alreadyFetched(day)) return const KuchniaRefresh();
final result = await refreshDelivery(day);
if (result.ok) await service.recordFetched(day);
// Only a fetch that found dishes counts: an outage, or a walk that missed
// the delivery, must not suppress the day's retries.
if (result.ok && result.dishes.isNotEmpty) await service.recordFetched(day);
return result;
}
}

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.101+1
version: 1.0.102+1
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,45 @@
/// The catering login's error messages.
///
/// A 401 is a wrong password, and on the phone the only place to fix that is
/// Settings -- so only a 401 points there. Any other status is the panel's
/// problem; sending the user to re-type a correct password would mislead.
library;
import 'package:diet_guard_app/services/kuchnia_client.dart';
import 'package:diet_guard_app/services/kuchnia_errors.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
const _settingsHint = 'check the password in Settings';
/// Logs in against a panel that answers the login with [status].
Future<KuchniaError> _loginError(int status) async {
final session = KuchniaSession(
username: 'me@example.com',
password: 'pw',
client: MockClient((_) async => http.Response('', status)),
);
try {
await session.login();
} on KuchniaError catch (error) {
return error;
}
fail('a $status login must throw KuchniaError');
}
void main() {
group('a rejected login', () {
test('401 points the user at the password in Settings', () async {
final error = await _loginError(401);
expect(error.message, contains('HTTP 401'));
expect(error.message, contains(_settingsHint));
});
test('500 is the panel\'s fault, not the password\'s', () async {
final error = await _loginError(500);
expect(error.message, contains('HTTP 500'));
expect(error.message, isNot(contains(_settingsHint)));
});
});
}

View File

@ -6,9 +6,58 @@
/// the live panel rather than guessed from its JavaScript.
library;
import 'dart:convert';
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';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// A panel serving [orders] (id -> deliveries) as active ids, newest first,
/// and a one-dish menu for any delivery. Every GET path lands in [requested].
KuchniaSession _session(
Map<String, List<Map<String, Object>>> orders,
List<String> requested,
) {
final client = MockClient((request) async {
final path = request.url.path;
requested.add(path);
if (path.endsWith('/order/active-ids')) {
return http.Response(jsonEncode(orders.keys.toList()), 200);
}
for (final id in orders.keys) {
if (path.endsWith('/order/$id')) {
return http.Response(jsonEncode({'deliveries': orders[id]}), 200);
}
}
if (path.contains('/menus/delivery/')) {
return http.Response(
jsonEncode({
'deliveryMenuMeal': [
{
'mealName': 'Obiad',
'menuMealName': 'Kaszotto',
'mealPriority': 1,
'nutrition': {
'weight': 300.0,
'calories': 400.0,
'protein': 25.0,
'carbohydrate': 45.0,
'fat': 12.0,
},
},
],
}),
200,
);
}
return http.Response('', 404);
});
// A seeded cookie skips the login: these tests are about the order walk.
return KuchniaSession(username: 'me', password: 'pw', client: client)
..sessionCookie = 'abc123';
}
void main() {
group('the order walk', () {
@ -29,10 +78,14 @@ void main() {
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('a malformed active-ids payload yields no orders', () {
expect(activeOrderIds('nope'), isEmpty);
expect(activeOrderIds(null), isEmpty);
expect(activeOrderIds(<Object>[]), isEmpty);
});
test('every active order id is returned, in the panel\'s order', () {
expect(activeOrderIds(['FUTURE', 'CURRENT']), ['FUTURE', 'CURRENT']);
});
test('formats the day the way the panel keys deliveries', () {
@ -41,6 +94,56 @@ void main() {
});
});
group('fetchDishes walks every active order', () {
final day = DateTime(2026, 10, 10);
test(
'finds today in the second order when the first is a renewal',
() async {
// The panel lists ids newest first: on 2026-10-10 a renewal starting
// 10-24 came first and today's delivery sat in the running order.
final requested = <String>[];
final session = _session({
'FUTURE': [
{'date': '2026-10-24', 'deliveryId': 'DEL_FUTURE'},
],
'CURRENT': [
{'date': '2026-10-10', 'deliveryId': 'DEL_TODAY'},
],
}, requested);
final dishes = await fetchDishes(session, day);
expect(dishes.single.name, 'Kaszotto');
expect(
requested,
contains(endsWith('/menus/delivery/DEL_TODAY/new')),
reason: 'the menu must be keyed by the current order\'s delivery',
);
expect(requested, isNot(contains(contains('DEL_FUTURE'))));
},
);
test('is empty when no active order has the day', () async {
final requested = <String>[];
final session = _session({
'FUTURE': [
{'date': '2026-10-24', 'deliveryId': 'DEL_FUTURE'},
],
'CURRENT': [
{'date': '2026-10-09', 'deliveryId': 'DEL_YESTERDAY'},
{'date': '2026-10-10', 'deliveryId': 'GONE', 'deleted': true},
],
}, requested);
expect(await fetchDishes(session, day), isEmpty);
// Both orders were read, and no menu was asked for.
expect(requested, contains(endsWith('/order/FUTURE')));
expect(requested, contains(endsWith('/order/CURRENT')));
expect(requested, isNot(contains(contains('/menus/'))));
});
});
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.

View File

@ -0,0 +1,127 @@
/// What `refreshOnce` records as "already fetched today".
///
/// Split out of `kuchnia_queue_test.dart` for the repo's 250-line cap. Only a
/// fetch that found dishes may record the day. An empty "no delivery" answer
/// is exactly what a walk that missed the delivery looks like (the 2026-10-10
/// renewal-first `active-ids` bug), and recording it would suppress every
/// retry until midnight. The assertions count **fetches**, not results.
///
/// `refreshOnce` has no session seam, so the panel is injected through
/// `http.runWithClient`: `KuchniaSession` falls back to `http.Client()`, which
/// resolves to the zone's client.
library;
import 'dart:convert';
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_credential_service.dart';
import 'package:diet_guard_app/services/kuchnia_import.dart';
import 'package:diet_guard_app/services/kuchnia_queue.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;
}
}
/// A panel whose one order has [deliveries]; [fetches] counts the walks.
MockClient _panel(List<Map<String, Object>> deliveries, List<int> fetches) =>
MockClient((request) async {
final path = request.url.path;
if (path.endsWith('/auth/login')) {
return http.Response(
'',
200,
headers: {'set-cookie': 'SESSION=abc123; Path=/'},
);
}
if (path.endsWith('/order/active-ids')) {
fetches[0]++;
return http.Response(jsonEncode(['ORD1']), 200);
}
if (path.endsWith('/order/ORD1')) {
return http.Response(jsonEncode({'deliveries': deliveries}), 200);
}
if (path.contains('/menus/delivery/DEL1')) {
return http.Response(
jsonEncode({
'deliveryMenuMeal': [
{
'mealName': 'Obiad',
'menuMealName': 'Kaszotto',
'mealPriority': 1,
'nutrition': {
'weight': 300.0,
'calories': 400.0,
'protein': 25.0,
'carbohydrate': 45.0,
'fat': 12.0,
},
},
],
}),
200,
);
}
return http.Response('', 404);
});
void main() {
late _MemoryStore store;
final today = DateTime(2026, 8, 23);
Future<KuchniaRefresh> refreshAgainst(http.Client panel) => http
.runWithClient(() => KuchniaQueueService.refreshOnce(today), () => panel);
setUp(() async {
store = _MemoryStore();
FoodBankService.resetForTesting(store: store);
KuchniaCredentialService.resetForTesting(store: store);
await KuchniaCredentialService.instance.save('me@example.com', 'pw');
await KuchniaQueueService.initForTesting(store);
});
tearDown(() {
KuchniaQueueService.resetForTesting();
FoodBankService.resetForTesting();
KuchniaCredentialService.resetForTesting();
});
test('an empty successful fetch does not record the day', () async {
final fetches = [0];
final panel = _panel(const [], fetches);
final first = await refreshAgainst(panel);
expect(first.ok, isTrue);
expect(first.dishes, isEmpty);
expect(KuchniaQueueService.instance.alreadyFetched(today), isFalse);
await refreshAgainst(panel);
expect(fetches[0], 2, reason: 'an empty answer must not suppress a retry');
});
test('a fetch that found dishes records the day', () async {
final fetches = [0];
final panel = _panel(const [
{'date': '2026-08-23', 'deliveryId': 'DEL1'},
], fetches);
final first = await refreshAgainst(panel);
expect(first.dishes.single.name, 'Kaszotto');
expect(KuchniaQueueService.instance.alreadyFetched(today), isTrue);
await refreshAgainst(panel);
expect(fetches[0], 1, reason: 'the guard must still stop a second walk');
});
}

View File

@ -15,11 +15,13 @@ still wins over the lazy hook.
from __future__ import annotations
from http import HTTPStatus
from importlib import import_module
import sys
import time
from typing import TYPE_CHECKING
from diet_guard import _kuchnia_config
from diet_guard._constants import (
KUCHNIA_API_BASE,
KUCHNIA_COMPANY,
@ -119,6 +121,13 @@ class PanelSession:
)
if not response.ok:
msg = f"catering login rejected (HTTP {response.status_code})"
if response.status_code == HTTPStatus.UNAUTHORIZED:
# The PC reads the hand-written file first, so that is the
# password to fix -- not the synced copy.
msg += (
" -- check the password in "
f"{_kuchnia_config.KUCHNIA_CREDENTIALS_FILE}"
)
raise KuchniaError(msg)
cookie = self._session.cookies.get(SESSION_COOKIE)
if cookie is None:

View File

@ -130,7 +130,8 @@ def refresh_delivery_once(day: date) -> tuple[list[Dish], str | None]:
if last_import_day() == stamp:
return [], None
dishes, reason = refresh_delivery(day)
if reason is None:
# Only a clean fetch counts: an outage must not suppress the retry.
if reason is None and dishes:
# Only a fetch that found dishes counts: an outage, or a walk that
# missed the delivery, must not suppress the day's retries.
record_import_day(stamp)
return dishes, reason

View File

@ -2,7 +2,9 @@
Three calls, and the shape of the walk is not what the endpoint names suggest:
1. ``company/customer/order/active-ids`` -> ``[orderId]``.
1. ``company/customer/order/active-ids`` -> ``[orderId, ...]``. More than one
while a renewal overlaps the running order, **newest first** -- so the first
id is the future order and today's delivery sits in a later one.
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 dish names, no macros.
@ -29,12 +31,12 @@ if TYPE_CHECKING: # pragma: no cover - typing only
from diet_guard._kuchnia_parse import Dish
def _active_order_id(session: PanelSession) -> object | None:
"""Return the first active order id, or None when there is no active order."""
def _active_order_ids(session: PanelSession) -> list[object]:
"""Return every active order id, empty when there is no active order."""
payload = session.get_json("company/customer/order/active-ids")
if not isinstance(payload, list) or not payload:
return None
return payload[0]
if not isinstance(payload, list):
return []
return payload
def _delivery_id_for(payload: object, wanted: str) -> object | None:
@ -76,11 +78,14 @@ def fetch_dishes(session: PanelSession, day: date) -> list[Dish]:
Raises:
KuchniaError: When the panel cannot be read.
"""
order_id = _active_order_id(session)
if order_id is None:
return []
order = session.get_json(f"company/customer/order/{order_id}")
delivery_id = _delivery_id_for(order, day.isoformat())
# Never trust the list's order: on 2026-10-10 a renewal starting 10-24
# came first, and reading only it made every day look delivery-free.
delivery_id = None
for order_id in _active_order_ids(session):
order = session.get_json(f"company/customer/order/{order_id}")
delivery_id = _delivery_id_for(order, day.isoformat())
if delivery_id is not None:
break
if delivery_id is None:
return []
menu = session.get_json(f"company/general/menus/delivery/{delivery_id}/new")

View File

@ -106,14 +106,23 @@ class TestAuthenticate:
assert session.calls == []
assert session.cookies["SESSION"] == "cached"
def test_a_rejected_login_raises(self, creds: None) -> None:
def test_a_rejected_login_names_the_password_to_fix(self, creds: None) -> None:
session = _session_with([FakeResponse(401, {})])
with (
patch.object(_kuchnia_client, "requests", fake_requests(session)),
pytest.raises(KuchniaError, match="login rejected"),
pytest.raises(KuchniaError, match=r"login rejected.*check the password"),
):
PanelSession().authenticate()
def test_a_server_error_at_login_blames_no_password(self, creds: None) -> None:
session = _session_with([FakeResponse(500, {})])
with (
patch.object(_kuchnia_client, "requests", fake_requests(session)),
pytest.raises(KuchniaError, match="login rejected") as raised,
):
PanelSession().authenticate()
assert "password" not in str(raised.value)
def test_a_login_that_sets_no_cookie_raises(self, creds: None) -> None:
session = _session_with([FakeResponse(200, {})])
session.login_cookie = None

View File

@ -163,6 +163,16 @@ class TestRefreshOnce:
_kuchnia_import.refresh_delivery_once(DAY)
assert refresh.call_count == 2, "a failed day must be retried"
def test_an_empty_fetch_does_not_suppress_the_retry(self) -> None:
# "No delivery" is what a walk that missed the order looks like too;
# stamping it hid a whole day's delivery on 2026-10-10.
with patch.object(
_kuchnia_import, "refresh_delivery", return_value=([], None)
) as refresh:
_kuchnia_import.refresh_delivery_once(DAY)
_kuchnia_import.refresh_delivery_once(DAY)
assert refresh.call_count == 2
def test_an_unreadable_marker_just_means_fetch_again(self) -> None:
marker = _kuchnia_config.KUCHNIA_LAST_IMPORT_FILE
marker.parent.mkdir(parents=True, exist_ok=True)

View File

@ -117,3 +117,42 @@ class TestFetchDishes:
panel, session = self._panel([FakeResponse(200, {"not": "a list"})])
with patch.object(_kuchnia_client, "requests", fake_requests(session)):
assert fetch_dishes(panel, DAY) == []
def test_every_active_order_is_walked_for_the_day(self, creds: None) -> None:
# The panel lists ids newest first: while a renewal overlaps the running
# order, the first id is the *future* order and today sits in the next.
panel, session = self._panel(
[
FakeResponse(200, [2, 1]),
FakeResponse(200, order_payload(date="2026-10-24", delivery_id=999)),
FakeResponse(200, order_payload(delivery_id=111)),
FakeResponse(200, menu_payload(2)),
],
)
with patch.object(_kuchnia_client, "requests", fake_requests(session)):
dishes = fetch_dishes(panel, DAY)
assert len(dishes) == 2
assert any("menus/delivery/111/new" in url for _, url in session.calls)
def test_the_walk_stops_at_the_first_order_with_the_day(self, creds: None) -> None:
panel, session = self._panel(
[
FakeResponse(200, [1, 2]),
FakeResponse(200, order_payload(delivery_id=111)),
FakeResponse(200, menu_payload(1)),
],
)
with patch.object(_kuchnia_client, "requests", fake_requests(session)):
fetch_dishes(panel, DAY)
assert not any(url.endswith("order/2") for _, url in session.calls)
def test_no_order_with_the_day_is_not_an_error(self, creds: None) -> None:
panel, session = self._panel(
[
FakeResponse(200, [2, 1]),
FakeResponse(200, order_payload(date="2026-10-24")),
FakeResponse(200, order_payload(date="2026-01-01")),
],
)
with patch.object(_kuchnia_client, "requests", fake_requests(session)):
assert fetch_dishes(panel, DAY) == []

View File

@ -52,7 +52,10 @@ responses on 2026-08-22. Re-check if the bundle hashes change.
The walk is three calls:
1. `company/customer/order/active-ids` → `[orderId]`
1. `company/customer/order/active-ids` → `[orderId, …]`, **newest first**. A
renewal overlapping the running order makes it two ids, and the first is
the *future* order — so every id is walked until one has the day's
delivery (2026-10-10: reading only `[0]` made every day look empty).
2. `company/customer/order/{orderId}` → the order, which **embeds every
delivery** with its date. No enumeration call needed — but `deliveryMeals`
carries ids only, no names and no macros.