mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
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
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:
parent
357bdb7e94
commit
11173c7088
@ -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']);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
45
app/test/services/kuchnia_client_test.dart
Normal file
45
app/test/services/kuchnia_client_test.dart
Normal 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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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.
|
||||
|
||||
127
app/test/services/kuchnia_queue_refresh_test.dart
Normal file
127
app/test/services/kuchnia_queue_refresh_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) == []
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user