diff --git a/.env.example b/.env.example index 60e70b1..b46513f 100644 --- a/.env.example +++ b/.env.example @@ -95,7 +95,10 @@ TELEGRAM_ENABLED=false # KOKORO_TTS_BASE_URL=http://100.x.y.z:8880 # KOKORO_TTS_VOICE=af_heart -# Optional Google Health read-only connector for private Telegram chats. +# Optional Google Health connector for private Telegram chats. It provides +# read-only summaries and, when both nutrition scopes are granted, exports +# future meal logs, edits, and deletions. Existing connections must reconnect +# to grant the nutrition scopes; older meals are never backfilled. # Configure a Google Cloud OAuth web client and enable Google Health first. # Production must use an HTTPS callback reachable by the user: # GOOGLE_HEALTH_REDIRECT_URI=https://api.example.com/integrations/google-health/callback diff --git a/docs/architecture.md b/docs/architecture.md index f793104..4eeb1bf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,8 +50,15 @@ The HTTPS callback consumes the state, exchanges the code, resolves Google's server-side identity, and stores only an encrypted refresh token plus safe connection metadata. A bounded background job refreshes and reads recent data, normalizes it into daily SQLite records, and the Telegram commands and -`get_health_summary` tool read those normalized records. Tokens, raw provider -payloads, and provider identifiers never travel through Telegram messages. +`get_health_summary` tool read those normalized records. When both nutrition +scopes are granted, meal mutations from eligible private chats also enqueue +durable, account-bound Google `nutrition-log` revisions. The export worker +retries pending work independently of health imports, preserves operation +ordering per meal, and exposes safe pending, synced, failed, and +authorization-required counts. Tokens, raw provider payloads, meal +descriptions, and provider identifiers never enter application logs or traces; +the user's own meal text can still appear in the private Telegram conversation +as the normal result of logging a meal. Long polling is outbound. It does not require a public webhook, domain, TLS certificate, or inbound application port. @@ -93,6 +100,7 @@ Blacki uses different stores for different responsibilities: | Optional Mem0 memory with Qdrant Cloud | Managed Qdrant | Provider-managed | | Zepto OAuth credentials | `/app/data/credentials/zepto-mcp-remote/` | Yes with the Compose volume | | Google Health refresh tokens and normalized summaries | SQLite (`tools.db`), tokens encrypted at rest | Yes with the Compose volume | +| Google Health meal export revisions and retry state | SQLite (`tools.db`), payloads account-bound and sent over HTTPS | Yes with the Compose volume | | Application logs and traces | JSON files under `/app/logs` | Yes with the Compose volume | Compose maps `.adk_state/`, `data/`, and `logs/` from the host. Back up the @@ -138,13 +146,22 @@ permissions; they are not encrypted. Shopping prompts, tool calls, and results remain in the local ADK session database and are sent to the configured model as part of normal agent execution. -Google Health is a separate read-only boundary. It uses the current Google -Health API, not the legacy Fitbit Web API. The connector requests only current -read-only activity/fitness, measurements, and sleep scopes; it handles missing -or partially imported categories as unavailable. Health commands reject group -chats, and the summary tool requires private Telegram session state. -`/disconnect_health` requires an explicit inline-button confirmation before -local deletion. +Google Health summaries are a separate read-only boundary. The connector uses +the current Google Health API, not the legacy Fitbit Web API. It requests the +current read-only activity/fitness, measurements, and sleep scopes plus +`googlehealth.nutrition.readonly` and `googlehealth.nutrition.writeonly` for +optional meal export. It handles missing or partially imported categories as +unavailable. Health commands reject group chats, and the summary tool requires +private Telegram session state. Meal export has no historical backfill and +keeps local save status separate from remote sync status. `/disconnect_health` +requires an explicit inline-button confirmation, cancels future meal sync, +retains local calorie logs, and does not purge records already sent to Google; +requests already submitted may still complete. + +The import and meal-export jobs share the existing scheduler process but remain +independent: health imports use the configured interval and meal export runs +every minute. Run only one active scheduler process per `tools.db`; the +deployment does not claim cross-process dispatch leases. ### Sandbox credential threat model diff --git a/docs/base-infra/environment-variables.md b/docs/base-infra/environment-variables.md index ac0bec0..1b512a8 100644 --- a/docs/base-infra/environment-variables.md +++ b/docs/base-infra/environment-variables.md @@ -138,11 +138,31 @@ identities. | `GOOGLE_HEALTH_OAUTH_STATE_TTL_SECONDS` | `600` | Lifetime of one-time OAuth state | Blacki requests the current Google Health read-only activity/fitness, -health-metrics/measurements, and sleep scopes. Do not paste the client secret -or Fernet key into logs, chat, or source control. The callback URL must exactly -match the Google Cloud OAuth client configuration. The Apple Health-to-Google -Health or Fitbit import step is configured separately by the user and may be -incomplete; Blacki does not access HealthKit directly. +health-metrics/measurements, and sleep scopes plus +`googlehealth.nutrition.readonly` and `googlehealth.nutrition.writeonly`. The +read-only scopes support summaries. Both nutrition scopes are +required for automatic export of future private-chat meal logs, edits, and +deletions. Existing connections must reconnect to request the added scopes; +Blacki never backfills meals logged before consent. Do not paste the client +secret or Fernet key into logs, chat, or source control. The callback URL must +exactly match the Google Cloud OAuth client configuration. The Apple +Health-to-Google Health or Fitbit import step is configured separately by the +user and may be incomplete; Blacki does not access HealthKit directly. + +The v4 discovery document lists `nutrition-log` as the write data type. Blacki +exports only the fields already present in a meal entry: food name, kcal, +available protein/carbohydrate/fat values, meal type, and the selected local +date. Unknown nutrients are omitted and no food database lookup is performed. +This contract was checked against Google's [Health scopes][health-scopes], +[v4 discovery document][health-discovery], [nutrition data type][health-nutrition], +and [data point REST reference][health-datapoints] before rollout. Do not +enable live health-record writes until the OAuth project has the nutrition +scopes configured and the resulting consent is verified. + +[health-scopes]: https://developers.google.com/health/scopes +[health-discovery]: https://health.googleapis.com/$discovery/rest?version=v4 +[health-nutrition]: https://developers.google.com/health/data-types/nutrition +[health-datapoints]: https://developers.google.com/health/reference/rest/v4/users.dataTypes.dataPoints ## Search and browser tools diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md index 3ed888d..b691917 100644 --- a/docs/telegram-setup.md +++ b/docs/telegram-setup.md @@ -87,30 +87,56 @@ retains the model's tool call and arguments. ### Optional Connect Google Health Blacki can read normalized health summaries after a user completes Google OAuth -from a private Telegram chat. This is intentionally named **Connect Google -Health**: Blacki does not request Apple ID credentials, access HealthKit, scrape -Fitbit, or receive arbitrary Apple Health records. The user must first configure -an Apple Health-to-Google Health/Fitbit-compatible import path if their account -and app version support it. +from a private Telegram chat. If the user grants both nutrition permissions, +Blacki also exports future meal logs, edits, and deletions from that private +chat. This is intentionally named **Connect Google Health**: Blacki does not +request Apple ID credentials, access HealthKit, scrape Fitbit, or receive +arbitrary Apple Health records. The user must first configure an Apple +Health-to-Google Health/Fitbit-compatible import path if their account and app +version support it. Configure the Google Cloud OAuth web client and the `GOOGLE_HEALTH_*` values in [Configuration](base-infra/environment-variables.md), then set the callback URL to the exact public HTTPS URL. In Telegram: 1. Send `/connect_health` in a private chat. -2. Open the one-time Google authorization link and grant only the requested - read-only categories. +2. Open the one-time Google authorization link. Grant the read-only categories + for summaries. Grant both `googlehealth.nutrition.readonly` and + `googlehealth.nutrition.writeonly` if you want future meal export. Existing + connections must reconnect to add these nutrition permissions. The nutrition + read permission lets Blacki verify records it created by exact data point + ID; it does not import unrelated food logs. 3. Return to Telegram and use `/health_refresh` for an on-demand sync or `/health_summary` for the latest stored records. -4. Use `/disconnect_health`, then confirm the button, to revoke the token - best-effort and delete Blacki's stored token, normalized records, and pending - OAuth state. +4. Log meals normally. Eligible new meals show a `google_health_sync` status; + `pending` is retried in the background, `synced` confirms the remote write, + `authorization_required` asks you to reconnect, and `failed` remains visible + for follow-up. A local Blacki save is still successful when remote sync is + pending or fails, and the meal must not be logged again. +5. Use `/disconnect_health`, then confirm the button, to revoke the token + best-effort, cancel pending meal sync, and remove Blacki's stored token and + normalized health summaries. Local calorie logs remain. Blacki does not + delete records already sent to Google Health, and requests already submitted + may still finish. The background sync runs every 12 hours by default and fetches a bounded recent window so late device imports can replace earlier daily records. Missing values are omitted rather than guessed. Stored data is limited to normalized daily activity, workout, sleep, heart-rate, weight, and body-fat summaries; raw -Google payloads and provider IDs are not persisted in the summary table. +Google payloads and provider IDs are not persisted in the summary table. Meal +exports are persisted separately with retry state and opaque data point IDs; +there is no historical backfill. The meal export worker runs every minute +independently of health imports. Run only one active scheduler process per +`tools.db` so a deployment does not dispatch duplicate work. + +Google's v4 discovery document currently lists `nutrition-log` as a supported +data type. Blacki writes only the local meal description, kcal, available +macros, meal type, and selected local date; it omits unknown nutrients and does +not substitute food-database estimates. See Google's [nutrition data type] +and [data point REST reference] for the provider contract. + +[nutrition data type]: https://developers.google.com/health/data-types/nutrition +[data point REST reference]: https://developers.google.com/health/reference/rest/v4/users.dataTypes.dataPoints Google Health availability does not prove that a particular Apple Health metric was imported. Test the desired categories on a non-production account before @@ -145,10 +171,10 @@ Open the bot in Telegram and send: | `/model` | Open the model and thinking settings panel | | `/thinking` | Open the supported reasoning-effort choices for the active model | | `/reset` | Start a fresh conversation session | -| `/connect_health` | Send a Google Health authorization link | +| `/connect_health` | Send a Google Health authorization link and consent for optional meal sync | | `/health_refresh` | Fetch recent Google Health data (rate limited) | | `/health_summary` | Show normalized daily records and trends | -| `/disconnect_health` | Confirm disconnection and local health-data deletion | +| `/disconnect_health` | Confirm disconnection and cancellation of meal sync | Then send a normal message and confirm the model responds. Blacki does not currently implement a `/clear` command. diff --git a/src/blacki/calories/service.py b/src/blacki/calories/service.py new file mode 100644 index 0000000..a70a47a --- /dev/null +++ b/src/blacki/calories/service.py @@ -0,0 +1,383 @@ +"""Atomic local meal mutations and optional Google Health enrollment.""" + +from __future__ import annotations + +import contextlib +import math +from datetime import UTC, datetime, time, timedelta +from typing import Any + +from blacki.container import AppContainer, get_container +from blacki.health.config import ( + GOOGLE_HEALTH_NUTRITION_SCOPES, + health_user_id_for_telegram_user, +) +from blacki.health.nutrition_storage import NutritionStorage +from blacki.utils.timezone import get_app_timezone + +from .storage import CalorieEntry + +VALID_MEAL_TYPES = frozenset({"breakfast", "lunch", "dinner", "snack"}) + + +def validate_meal(entry: CalorieEntry) -> None: + """Validate fields shared by create and edit mutations.""" + if not entry.description.strip(): + raise ValueError("description cannot be empty") + if entry.calories <= 0: + raise ValueError("estimated_calories must be > 0") + if entry.meal_type not in {None, *VALID_MEAL_TYPES}: + raise ValueError("meal_type must be breakfast, lunch, dinner, or snack") + for value in (entry.protein_g, entry.carbs_g, entry.fat_g): + if value is not None and (not math.isfinite(value) or value < 0): + raise ValueError("macros must be finite and nonnegative") + + +def nutrition_payload(entry: CalorieEntry) -> dict[str, Any]: + """Map a meal to the verified Google Health NutritionLog wire shape. + + Unknown nutrients are omitted. ``serving`` is intentionally omitted: the + Health API requires a verified ``foodMeasurementUnit`` when that object is + present, and Blacki has no source for a truthful unit. + """ + tz = get_app_timezone() + logged = datetime.fromisoformat(entry.logged_at) + if logged.tzinfo is None: + logged = logged.replace(tzinfo=tz) + else: + logged = logged.astimezone(tz) + meal_date = datetime.strptime(entry.logged_date, "%Y-%m-%d").date() + start = ( + logged + if logged.date() == meal_date + else datetime.combine(meal_date, time(12), tzinfo=tz) + ) + end = start + timedelta(seconds=1) + start_offset = start.utcoffset() + end_offset = end.utcoffset() + if start_offset is None or end_offset is None: # pragma: no cover + raise ValueError("meal timezone offset is unavailable") + nutrition: dict[str, Any] = { + "interval": { + "startTime": start.astimezone(UTC).isoformat().replace("+00:00", "Z"), + "endTime": end.astimezone(UTC).isoformat().replace("+00:00", "Z"), + "startUtcOffset": f"{int(start_offset.total_seconds())}s", + "endUtcOffset": f"{int(end_offset.total_seconds())}s", + }, + "foodDisplayName": entry.description, + "energy": {"kcal": entry.calories}, + } + if entry.meal_type: + nutrition["mealType"] = entry.meal_type.upper() + nutrients: list[dict[str, Any]] = [] + if entry.protein_g is not None: + nutrients.append( + {"nutrient": "PROTEIN", "quantity": {"grams": entry.protein_g}} + ) + if nutrients: + nutrition["nutrients"] = nutrients + if entry.carbs_g is not None: + nutrition["totalCarbohydrate"] = {"grams": entry.carbs_g} + if entry.fat_g is not None: + nutrition["totalFat"] = {"grams": entry.fat_g} + return {"nutritionLog": nutrition} + + +class MealService: + """Commit a calorie mutation and its export intent in one transaction.""" + + def __init__(self, container: AppContainer) -> None: + self.container = container + self._nutrition: NutritionStorage | Any | None = None + + async def _get_nutrition_storage(self) -> NutritionStorage | Any: + health = self.container.google_health_storage + nutrition = getattr(health, "nutrition", None) + if nutrition is None: + nutrition = NutritionStorage(self.container.conn, self.container.lock) + self._nutrition = nutrition + await nutrition.initialize() + return nutrition + + async def mutate( + self, + user_id: str, + *, + private: bool = False, + entry: CalorieEntry | None = None, + entry_id: int | None = None, + updates: dict[str, Any] | None = None, + ) -> tuple[int, str]: + """Create, edit, or delete a meal and return its sync status. + + ``entry`` selects create, a non-empty ``updates`` mapping selects edit, + and ``entry=None`` with ``entry_id`` selects delete. All local writes + and export revisions happen under the same SQLite transaction. + """ + if entry_id is None and entry is None: + raise ValueError("A new meal is required") + if entry_id is not None and entry is not None: + raise ValueError("entry and entry_id cannot both select a new meal") + + container = self.container + calorie_storage = container.calorie_storage + health = container.google_health_storage + nutrition = await self._get_nutrition_storage() + await calorie_storage.initialize() + await health.initialize() + + async with container.lock: + await container.conn.execute("BEGIN") + try: + original: CalorieEntry | None = None + if entry_id is not None: + async with container.conn.execute( + "SELECT * FROM calorie_logs WHERE id = ? AND user_id = ?", + (entry_id, user_id), + ) as cursor: + row = await cursor.fetchone() + if row is None: + raise ValueError("Meal not found or you do not have permission") + original = CalorieEntry.model_validate(dict(row)) + + if entry is not None: + if entry.user_id != user_id: + raise ValueError("Meal owner does not match the current user") + validate_meal(entry) + created = True + elif updates: + if original is None: # pragma: no cover + raise RuntimeError("edit requires the original meal row") + entry = CalorieEntry.model_validate( + {**original.model_dump(), **updates} + ) + validate_meal(entry) + created = False + elif entry_id is not None: + created = False + else: # pragma: no cover + raise ValueError("A new meal is required") + + if entry_id is None: + if entry is None: # pragma: no cover + raise RuntimeError("create requires a meal entry") + async with container.conn.execute( + """ + INSERT INTO calorie_logs + (user_id, description, calories, protein_g, carbs_g, + fat_g, meal_type, logged_at, logged_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + entry.description, + entry.calories, + entry.protein_g, + entry.carbs_g, + entry.fat_g, + entry.meal_type, + entry.logged_at, + entry.logged_date, + ), + ) as cursor: + inserted_id = cursor.lastrowid + if inserted_id is None: # pragma: no cover + raise RuntimeError("Meal insert did not return an identifier") + entry_id = int(inserted_id) + elif entry is None: + await container.conn.execute( + "DELETE FROM calorie_logs WHERE id = ? AND user_id = ?", + (entry_id, user_id), + ) + else: + await container.conn.execute( + """ + UPDATE calorie_logs + SET description = ?, calories = ?, protein_g = ?, + carbs_g = ?, fat_g = ?, meal_type = ?, + logged_at = ?, logged_date = ? + WHERE id = ? AND user_id = ? + """, + ( + entry.description, + entry.calories, + entry.protein_g, + entry.carbs_g, + entry.fat_g, + entry.meal_type, + entry.logged_at, + entry.logged_date, + entry_id, + user_id, + ), + ) + + sync_status = await self._enqueue_export( + nutrition=nutrition, + health=health, + user_id=user_id, + private=private, + entry_id=entry_id, + entry=entry, + original=original, + created=created, + updates=updates, + ) + await container.conn.execute("COMMIT") + if sync_status == "pending" and container.nutrition_export_worker: + container.nutrition_export_worker.wake() + return entry_id, sync_status + except BaseException: + with contextlib.suppress(Exception): + await container.conn.execute("ROLLBACK") + raise + + async def _enqueue_export( + self, + *, + nutrition: NutritionStorage | Any, + health: Any, + user_id: str, + private: bool, + entry_id: int, + entry: CalorieEntry | None, + original: CalorieEntry | None, + created: bool, + updates: dict[str, Any] | None, + ) -> str: + canonical = health_user_id_for_telegram_user(user_id) if private else None + connection = ( + await health.get_connection(canonical) if canonical is not None else None + ) + previous = await nutrition.meal(entry_id) + if previous is not None and str(previous["status"]) == "cancelled": + return "not_enabled" + + eligible = _nutrition_authorized(connection) + existing_account = ( + str(previous["health_user_id"]) + if previous is not None and previous.get("health_user_id") + else None + ) + connection_account = ( + connection.health_user_id if connection is not None else existing_account + ) + account_matches = ( + existing_account is None + or connection_account is None + or existing_account == connection_account + ) + + # Only a newly created private meal with both nutrition scopes enrolls. + # A connection added later must not backfill meals that predate consent. + should_enqueue = ( + private + and account_matches + and ((created and eligible) or (not created and previous is not None)) + ) + if not should_enqueue: + if created and connection is not None and not eligible: + return "authorization_required" + return "not_enabled" + + health_user_id = existing_account or ( + connection.health_user_id if connection is not None else "" + ) + if not health_user_id: + return "authorization_required" + + if entry is None: + target = await self._latest_remote_resource(nutrition, entry_id) + await nutrition.enqueue( + meal_id=entry_id, + owner_id=user_id, + telegram_user_id=canonical or user_id, + health_user_id=health_user_id, + payload=None, + operation="delete", + target_resource_name=target, + ) + else: + payload = nutrition_payload(entry) + if previous is not None and ( + updates is None or "logged_date" not in updates + ): + prior_payload = await nutrition.latest_payload(entry_id) + prior_interval = _interval_from_payload(prior_payload) + if prior_interval is not None: + payload["nutritionLog"]["interval"] = prior_interval + + # Google Health's anonymous data points cannot be edited. Delete a + # previously reconciled/in-flight point before creating its revision. + if ( + previous is not None + and str(previous.get("desired_operation")) == "upsert" + ): + target = await self._latest_remote_resource(nutrition, entry_id) + if target is not None: + await nutrition.enqueue( + meal_id=entry_id, + owner_id=user_id, + telegram_user_id=canonical or user_id, + health_user_id=health_user_id, + payload=None, + operation="delete", + target_resource_name=target, + ) + await nutrition.enqueue( + meal_id=entry_id, + owner_id=user_id, + telegram_user_id=canonical or user_id, + health_user_id=health_user_id, + payload=payload, + operation="upsert", + ) + + if eligible: + return "pending" + await container_execute( + self.container.conn, + "UPDATE nutrition_exports SET status = ? WHERE meal_id = ?", + ("authorization_required", entry_id), + ) + return "authorization_required" + + async def _latest_remote_resource( + self, nutrition: NutritionStorage | Any, meal_id: int + ) -> str | None: + revisions = await nutrition.revisions(meal_id) + for revision in reversed(revisions): + if revision.get("operation", "upsert") != "upsert": + continue + state = str(revision.get("state", "queued")) + if state in {"synced", "in_flight", "uncertain"}: + return str(revision["resource_name"]) + return None + + +async def container_execute(conn: Any, query: str, params: tuple[Any, ...]) -> None: + """Execute a mutation using the caller's already-held transaction.""" + await conn.execute(query, params) + + +def _nutrition_authorized(connection: Any | None) -> bool: + return bool( + connection is not None + and connection.status == "connected" + and set(GOOGLE_HEALTH_NUTRITION_SCOPES) <= set(connection.scopes) + ) + + +def _interval_from_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None: + if payload is None: + return None + nutrition = payload.get("nutritionLog") + if not isinstance(nutrition, dict): + return None + interval = nutrition.get("interval") + return dict(interval) if isinstance(interval, dict) else None + + +def get_meal_service() -> MealService: + """Return a meal service bound to the process container.""" + return MealService(get_container()) diff --git a/src/blacki/calories/tools.py b/src/blacki/calories/tools.py index 4dd04f8..fcea257 100644 --- a/src/blacki/calories/tools.py +++ b/src/blacki/calories/tools.py @@ -1,4 +1,5 @@ import logging +import math from datetime import timedelta from typing import Any @@ -10,6 +11,7 @@ from blacki.utils.preferences import get_preferences_storage from blacki.utils.timezone import get_app_timezone, now_utc +from .service import VALID_MEAL_TYPES, get_meal_service, validate_meal from .storage import CalorieEntry, get_storage logger = logging.getLogger(__name__) @@ -41,11 +43,10 @@ async def log_meal( if not description.strip(): return {"status": "error", "message": "description cannot be empty"} - valid_meal_types = {"breakfast", "lunch", "dinner", "snack"} - if meal_type and meal_type.lower() not in valid_meal_types: + if meal_type and meal_type.lower() not in VALID_MEAL_TYPES: return { "status": "error", - "message": f"meal_type must be one of {valid_meal_types}", + "message": f"meal_type must be one of {set(VALID_MEAL_TYPES)}", } user_id = tool_context.user_id @@ -66,27 +67,49 @@ async def log_meal( logged_at=now.isoformat(timespec="seconds"), logged_date=local_date, ) + validate_meal(entry) + + service = _try_get_meal_service() + if service is None: + storage = get_storage() + entry_id = await storage.add_entry(entry) + google_health_sync = "not_enabled" + else: + entry_id, google_health_sync = await service.mutate( + user_id, + private=_is_private_tool_context(tool_context), + entry=entry, + ) storage = get_storage() - entry_id = await storage.add_entry(entry) # Get running daily total - summary = await storage.get_daily_summary(user_id, local_date) - - # Get user goal - pref_storage = get_preferences_storage() - goal = await pref_storage.get(user_id, "calorie_goal", DEFAULT_CALORIE_GOAL) - - remaining = goal - summary.total_calories - - return { + result: dict[str, Any] = { "status": "success", "entry_id": entry_id, - "message": f"Logged {estimated_calories} kcal for '{description}'.", - "daily_total": summary.total_calories, - "calorie_goal": goal, - "remaining": remaining, + "message": _meal_saved_message( + f"Logged {estimated_calories} kcal for '{description}'.", + google_health_sync, + ), + "google_health_sync": google_health_sync, } + try: + summary = await storage.get_daily_summary(user_id, local_date) + result["daily_total"] = summary.total_calories + except Exception: + logger.exception("Failed to read meal summary after local commit") + result["message"] += ( + " The meal was saved, but the daily summary is unavailable." + ) + try: + pref_storage = get_preferences_storage() + goal = await pref_storage.get(user_id, "calorie_goal", DEFAULT_CALORIE_GOAL) + result["calorie_goal"] = goal + if "daily_total" in result: + result["remaining"] = goal - result["daily_total"] + except Exception: + logger.exception("Failed to read calorie goal after local commit") + return result except ValidationError as e: return {"status": "error", "message": f"Validation failed: {str(e)}"} except ValueError as e: @@ -182,6 +205,11 @@ async def edit_meal( except ValueError as e: return {"status": "error", "message": str(e)} if meal_type is not None: # pragma: no cover + if meal_type.lower() not in VALID_MEAL_TYPES: + return { + "status": "error", + "message": f"meal_type must be one of {set(VALID_MEAL_TYPES)}", + } updates["meal_type"] = meal_type.lower() if protein_g is not None: # pragma: no cover updates["protein_g"] = protein_g @@ -190,23 +218,55 @@ async def edit_meal( if fat_g is not None: # pragma: no cover updates["fat_g"] = fat_g + if description is not None and not description.strip(): + return {"status": "error", "message": "description cannot be empty"} + if estimated_calories is not None and estimated_calories <= 0: + return {"status": "error", "message": "estimated_calories must be > 0"} + for value in (protein_g, carbs_g, fat_g): + if value is not None and ( + not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0 + ): + return { + "status": "error", + "message": "macros must be finite and nonnegative", + } + if not updates: # pragma: no cover return {"status": "error", "message": "No fields provided to update"} try: - storage = get_storage() - updated = await storage.update_entry(entry_id, user_id, **updates) + service = _try_get_meal_service() + if service is None: + storage = get_storage() + updated = await storage.update_entry(entry_id, user_id, **updates) + sync_status = "not_enabled" + else: + _, sync_status = await service.mutate( + user_id, + private=_is_private_tool_context(tool_context), + entry_id=entry_id, + updates=updates, + ) + updated = True if updated: - return {"status": "success", "message": f"Updated entry {entry_id}"} + return { + "status": "success", + "message": _meal_saved_message( + f"Updated entry {entry_id}", sync_status + ), + "google_health_sync": sync_status, + } else: # pragma: no cover return { "status": "error", "message": f"Entry {entry_id} not found or you don't have permission", } - except Exception as e: + except ValueError as e: + return {"status": "error", "message": str(e)} + except Exception: logger.exception(f"Failed to edit meal entry {entry_id}") - return {"status": "error", "message": f"An unexpected error occurred: {str(e)}"} + return {"status": "error", "message": "An unexpected error occurred"} async def delete_meal( @@ -219,19 +279,37 @@ async def delete_meal( return {"status": "error", "message": "Missing user_id in tool_context"} try: - storage = get_storage() - deleted = await storage.delete_entry(entry_id, user_id) + service = _try_get_meal_service() + if service is None: + storage = get_storage() + deleted = await storage.delete_entry(entry_id, user_id) + sync_status = "not_enabled" + else: + _, sync_status = await service.mutate( + user_id, + private=_is_private_tool_context(tool_context), + entry_id=entry_id, + ) + deleted = True if deleted: - return {"status": "success", "message": f"Deleted entry {entry_id}"} + return { + "status": "success", + "message": _meal_saved_message( + f"Deleted entry {entry_id}", sync_status + ), + "google_health_sync": sync_status, + } else: # pragma: no cover return { "status": "error", "message": f"Entry {entry_id} not found or you don't have permission", } - except Exception as e: + except ValueError as e: + return {"status": "error", "message": str(e)} + except Exception: logger.exception(f"Failed to delete meal entry {entry_id}") - return {"status": "error", "message": f"An unexpected error occurred: {str(e)}"} + return {"status": "error", "message": "An unexpected error occurred"} async def set_calorie_goal( @@ -257,3 +335,33 @@ async def set_calorie_goal( "message": f"Daily calorie goal set to {daily_calories} kcal", "new_goal": daily_calories, } + + +def _try_get_meal_service() -> Any | None: + """Use the atomic service when the application container is available. + + The fallback keeps direct unit-level tool use compatible with the storage + singleton; production startup always initializes the application container. + """ + try: + return get_meal_service() + except RuntimeError: + return None + + +def _is_private_tool_context(tool_context: ToolContext) -> bool: + state = getattr(tool_context, "state", None) + if state is None: + return False + getter = getattr(state, "get", None) + return bool(getter("telegram_chat_type") == "private") if getter else False + + +def _meal_saved_message(message: str, sync_status: str) -> str: + if sync_status == "pending": + return f"{message} Saved in Blacki; Google Health sync is pending." + if sync_status == "authorization_required": + return f"{message} Saved in Blacki; reconnect Google Health to sync it." + if sync_status == "failed": + return f"{message} Saved in Blacki; Google Health sync failed and will retry." + return f"{message} Saved in Blacki." diff --git a/src/blacki/container.py b/src/blacki/container.py index fe17ece..910e07f 100644 --- a/src/blacki/container.py +++ b/src/blacki/container.py @@ -28,6 +28,7 @@ from blacki.calories.storage import SqliteCalorieStorage from blacki.declarative_db.storage import SqliteDeclarativeDbStorage + from blacki.health.nutrition_worker import NutritionExportWorker from blacki.health.storage import SqliteGoogleHealthStorage from blacki.reminders.storage import SqliteReminderStorage from blacki.telegram.access import TelegramAccessStorage @@ -150,6 +151,13 @@ class AppContainer: _telegram_access_storage: TelegramAccessStorage | None = field( default=None, init=False, repr=False ) + # Set by the server once the Google Health export worker is running, so + # MealService.mutate() can wake the dispatch loop right after a commit + # instead of waiting up to 60s for the next poll. None when the + # connector is not configured or not running (e.g. most tests). + nutrition_export_worker: NutritionExportWorker | None = field( + default=None, init=False, repr=False + ) @classmethod async def create(cls, sqlite_path: str | Path) -> Self: diff --git a/src/blacki/health/client.py b/src/blacki/health/client.py index 8f0b922..052860a 100644 --- a/src/blacki/health/client.py +++ b/src/blacki/health/client.py @@ -3,8 +3,12 @@ from __future__ import annotations import logging +import re from collections.abc import Mapping from dataclasses import dataclass +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime +from math import isfinite from typing import Any import httpx @@ -28,10 +32,14 @@ def __init__( *, status_code: int | None = None, error_code: str | None = None, + retry_after_seconds: float | None = None, + transport: bool = False, ) -> None: super().__init__(message) self.status_code = status_code self.error_code = error_code + self.retry_after_seconds = retry_after_seconds + self.transport = transport class GoogleHealthAuthError(GoogleHealthApiError): @@ -56,6 +64,21 @@ class GoogleHealthIdentity: legacy_fitbit_user_id: str | None +@dataclass(frozen=True, slots=True) +class GoogleHealthOperation: + """The safe subset of a Health API long-running operation.""" + + done: bool + name: str | None = None + error_code: str | None = None + response: dict[str, Any] | None = None + + @property + def successful(self) -> bool: + """Return true only for a completed operation without an error.""" + return self.done and self.error_code is None + + class GoogleHealthClient: """Async client that never logs OAuth tokens or health payloads.""" @@ -166,14 +189,75 @@ async def list_data_points( return points page_token = next_page_token + async def create_nutrition_log( + self, + access_token: str, + resource_name: str, + payload: Mapping[str, Any], + ) -> GoogleHealthOperation: + """Create one named anonymous nutrition log. + + The API requires the canonical account in both the parent path and the + DataPoint ``name``. ``payload`` is the DataPoint value, usually + ``{"nutritionLog": ...}``; this method adds only the immutable name. + """ + parent = _nutrition_parent(resource_name) + body = dict(payload) + body["name"] = resource_name + result = await self._api_request( + "POST", + f"/v4/{parent}/dataPoints", + access_token=access_token, + json_body=body, + ) + return _parse_operation(result) + + async def get_data_point( + self, access_token: str, resource_name: str + ) -> dict[str, Any]: + """Fetch one exact named data point for write reconciliation.""" + _nutrition_parent(resource_name) + return await self._api_request( + "GET", + f"/v4/{resource_name}", + access_token=access_token, + ) + + async def delete_nutrition_log( + self, access_token: str, resource_name: str + ) -> GoogleHealthOperation: + """Request deletion of one named nutrition log.""" + parent = _nutrition_parent(resource_name) + result = await self._api_request( + "POST", + f"/v4/{parent}/dataPoints:batchDelete", + access_token=access_token, + json_body={"names": [resource_name]}, + ) + return _parse_operation(result) + async def _token_request(self, form: Mapping[str, str]) -> dict[str, Any]: client = await self._ensure_client() - response = await client.post( - GOOGLE_HEALTH_TOKEN_URL, data=dict(form), timeout=30.0 + try: + response = await client.post( + GOOGLE_HEALTH_TOKEN_URL, data=dict(form), timeout=30.0 + ) + except httpx.RequestError as exc: + raise GoogleHealthApiError( + "Google OAuth request could not reach the provider", + error_code="transport_error", + transport=True, + ) from exc + payload = _json_object( + response, retry_after_seconds=_retry_after_seconds(response) ) - payload = _json_object(response) if response.status_code >= 400: - _raise_provider_error(payload, response.status_code, token_endpoint=True) + _raise_provider_error( + payload, + response.status_code, + token_endpoint=True, + retry_after_seconds=_retry_after_seconds(response), + ) return payload async def _api_request( @@ -183,21 +267,37 @@ async def _api_request( *, access_token: str, params: Mapping[str, Any] | None = None, + json_body: Mapping[str, Any] | None = None, ) -> dict[str, Any]: client = await self._ensure_client() - response = await client.request( - method, - f"{GOOGLE_HEALTH_API_BASE_URL}{path}", - headers={ - "Authorization": f"Bearer {access_token}", - "Accept": "application/json", - }, - params=dict(params) if params is not None else None, - timeout=30.0, + try: + response = await client.request( + method, + f"{GOOGLE_HEALTH_API_BASE_URL}{path}", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + params=dict(params) if params is not None else None, + json=dict(json_body) if json_body is not None else None, + timeout=30.0, + ) + except httpx.RequestError as exc: + raise GoogleHealthApiError( + "Google Health request could not reach the provider", + error_code="transport_error", + transport=True, + ) from exc + payload = _json_object( + response, retry_after_seconds=_retry_after_seconds(response) ) - payload = _json_object(response) if response.status_code >= 400: - _raise_provider_error(payload, response.status_code, token_endpoint=False) + _raise_provider_error( + payload, + response.status_code, + token_endpoint=False, + retry_after_seconds=_retry_after_seconds(response), + ) return payload async def _ensure_client(self) -> httpx.AsyncClient: @@ -238,22 +338,32 @@ def _parse_token_response( ) -def _json_object(response: httpx.Response) -> dict[str, Any]: +def _json_object( + response: httpx.Response, *, retry_after_seconds: float | None = None +) -> dict[str, Any]: try: payload = response.json() except ValueError as exc: raise GoogleHealthApiError( - "Google returned a non-JSON response", status_code=response.status_code + "Google returned a non-JSON response", + status_code=response.status_code, + retry_after_seconds=retry_after_seconds, ) from exc if not isinstance(payload, dict): raise GoogleHealthApiError( - "Google returned an unexpected response", status_code=response.status_code + "Google returned an unexpected response", + status_code=response.status_code, + retry_after_seconds=retry_after_seconds, ) return payload def _raise_provider_error( - payload: Mapping[str, Any], status_code: int, *, token_endpoint: bool + payload: Mapping[str, Any], + status_code: int, + *, + token_endpoint: bool, + retry_after_seconds: float | None = None, ) -> None: raw_error = payload.get("error") safe_error_code: str | None = None @@ -281,7 +391,77 @@ def _raise_provider_error( error_type = ( GoogleHealthAuthError if status_code in {401, 403} else GoogleHealthApiError ) - raise error_type(message, status_code=status_code, error_code=safe_error_code) + raise error_type( + message, + status_code=status_code, + error_code=safe_error_code, + retry_after_seconds=retry_after_seconds, + ) + + +def _parse_operation(payload: Mapping[str, Any]) -> GoogleHealthOperation: + """Parse an Operation without exposing provider payloads in exceptions.""" + done = payload.get("done") + if not isinstance(done, bool): + raise GoogleHealthApiError("Google Health operation response was incomplete") + raw_name = payload.get("name") + name = raw_name if isinstance(raw_name, str) and raw_name else None + raw_error = payload.get("error") + error_code: str | None = None + if isinstance(raw_error, Mapping): + status = raw_error.get("status") + if isinstance(status, str) and status.isascii() and status.isprintable(): + error_code = status[:80] + elif isinstance(raw_error.get("code"), int): + error_code = f"provider_error_{raw_error['code']}" + else: + error_code = "provider_error" + elif isinstance(raw_error, str) and raw_error.isascii() and raw_error.isprintable(): + error_code = raw_error[:80] + elif raw_error is not None: + error_code = "provider_error" + raw_response = payload.get("response") + response = dict(raw_response) if isinstance(raw_response, Mapping) else None + if done and error_code is None and response is None: + raise GoogleHealthApiError("Google Health operation response was incomplete") + return GoogleHealthOperation( + done=done, + name=name, + error_code=error_code, + response=response, + ) + + +def _nutrition_parent(resource_name: str) -> str: + """Validate and return the canonical parent for a nutrition data point.""" + match = re.fullmatch( + r"users/[^/?#]+/dataTypes/nutrition-log/dataPoints/" + r"[a-z0-9-]{4,63}", + resource_name, + ) + if match is None: + raise ValueError("resource_name is not a valid nutrition data point name") + return resource_name.rsplit("/dataPoints/", 1)[0] + + +def _retry_after_seconds(response: httpx.Response) -> float | None: + """Parse a bounded Retry-After header without trusting arbitrary values.""" + raw = response.headers.get("Retry-After") + if raw is None: + return None + try: + seconds = float(raw) + except ValueError: + try: + retry_at = parsedate_to_datetime(raw) + except (TypeError, ValueError, OverflowError): + return None + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=UTC) + seconds = retry_at.timestamp() - datetime.now(UTC).timestamp() + if not isfinite(seconds): + return None + return max(0.0, seconds) def _filter_for_data_type(data_type: str, start_time: str, end_time: str) -> str: diff --git a/src/blacki/health/config.py b/src/blacki/health/config.py index 2f38037..40c7360 100644 --- a/src/blacki/health/config.py +++ b/src/blacki/health/config.py @@ -17,11 +17,16 @@ GOOGLE_HEALTH_DEFAULT_REDIRECT_URI = ( "http://127.0.0.1:8080/integrations/google-health/callback" ) -GOOGLE_HEALTH_SCOPES = ( +GOOGLE_HEALTH_READ_SCOPES = ( "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", "https://www.googleapis.com/auth/googlehealth.sleep.readonly", ) +GOOGLE_HEALTH_NUTRITION_SCOPES = ( + "https://www.googleapis.com/auth/googlehealth.nutrition.readonly", + "https://www.googleapis.com/auth/googlehealth.nutrition.writeonly", +) +GOOGLE_HEALTH_SCOPES = GOOGLE_HEALTH_READ_SCOPES + GOOGLE_HEALTH_NUTRITION_SCOPES _TELEGRAM_HEALTH_USER_PATTERN = re.compile( r"^telegram-chat-(?P-?\d+)(?:-thread-\d+)?$" diff --git a/src/blacki/health/nutrition_storage.py b/src/blacki/health/nutrition_storage.py new file mode 100644 index 0000000..6cfc47f --- /dev/null +++ b/src/blacki/health/nutrition_storage.py @@ -0,0 +1,276 @@ +"""Durable, account-bound meal export state. + +Calorie rows remain the local source of truth. This module stores the latest +desired revision plus immutable create revisions for Google Health. A delete +sets ``desired_revision`` to NULL while retaining the revisions the worker must +remove remotely, so local deletion never loses reconciliation intent. +""" + +from __future__ import annotations + +import json +from typing import Any +from uuid import uuid4 + +from blacki.storage.base import SqlStorage + +_MISSING = object() + + +class NutritionStorage(SqlStorage): + """Store desired meals and immutable remote create revisions.""" + + async def _create_tables(self) -> None: + await self._conn.executescript( + """ + CREATE TABLE IF NOT EXISTS nutrition_exports ( + meal_id INTEGER PRIMARY KEY, + owner_id TEXT NOT NULL, + telegram_user_id TEXT NOT NULL, + health_user_id TEXT NOT NULL, + desired_revision TEXT, + desired_operation TEXT, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt REAL NOT NULL DEFAULT 0, + error_code TEXT + ); + CREATE TABLE IF NOT EXISTS nutrition_revisions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + meal_id INTEGER NOT NULL REFERENCES nutrition_exports(meal_id), + resource_name TEXT, + operation TEXT NOT NULL, + payload_json TEXT, + state TEXT NOT NULL DEFAULT 'queued' + ); + CREATE INDEX IF NOT EXISTS nutrition_due + ON nutrition_exports(status, next_attempt); + CREATE INDEX IF NOT EXISTS nutrition_meal_revisions + ON nutrition_revisions(meal_id, sequence); + """ + ) + + async def enqueue( + self, + *, + meal_id: int, + owner_id: str, + telegram_user_id: str, + health_user_id: str, + payload: dict[str, Any] | None, + operation: str, + target_resource_name: str | None = None, + ) -> None: + """Persist the newest desired create or deletion in the caller tx. + + ``operation`` is ``"upsert"`` (mints a fresh opaque Google data-point + resource name and requires ``payload``) or ``"delete"`` (removes + ``target_resource_name``, which may be ``None`` when nothing was ever + dispatched remotely). The worker resolves prior in-flight/uncertain + revisions before allowing a replacement create. + """ + if operation not in {"upsert", "delete"}: + raise ValueError(f"unsupported nutrition export operation: {operation}") + + existing = await self._fetch_one( + "SELECT owner_id, telegram_user_id, health_user_id " + "FROM nutrition_exports WHERE meal_id = ?", + (meal_id,), + ) + if existing is not None: + if str(existing["owner_id"]) != owner_id: + raise ValueError("nutrition export owner cannot change") + if str(existing["telegram_user_id"]) != telegram_user_id: + raise ValueError("nutrition export identity cannot change") + old_health_user_id = str(existing["health_user_id"]) + if old_health_user_id and old_health_user_id != health_user_id: + raise ValueError("nutrition export account cannot change") + + if operation == "upsert": + if payload is None: + raise ValueError("upsert requires a payload") + resource_name: str | None = ( + f"users/{health_user_id}/dataTypes/nutrition-log/dataPoints/" + f"blacki-{uuid4()}" + ) + else: + resource_name = target_resource_name + + await self._conn.execute( + """ + INSERT INTO nutrition_exports + (meal_id, owner_id, telegram_user_id, health_user_id, + desired_revision, desired_operation, status, attempts, + next_attempt, error_code) + VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, 0, NULL) + ON CONFLICT(meal_id) DO UPDATE SET + desired_revision = excluded.desired_revision, + desired_operation = excluded.desired_operation, + status = 'pending', attempts = 0, next_attempt = 0, + error_code = NULL + """, + ( + meal_id, + owner_id, + telegram_user_id, + health_user_id, + resource_name, + operation, + ), + ) + if resource_name is not None: + await self._conn.execute( + """ + INSERT INTO nutrition_revisions + (meal_id, resource_name, operation, payload_json, state) + VALUES (?, ?, ?, ?, 'queued') + """, + ( + meal_id, + resource_name, + operation, + json.dumps(payload, allow_nan=False) + if payload is not None + else None, + ), + ) + + async def meal(self, meal_id: int) -> dict[str, Any] | None: + """Return the desired export row for one meal.""" + return await self._fetch_one( + "SELECT * FROM nutrition_exports WHERE meal_id = ?", (meal_id,) + ) + + async def due(self, now: float) -> list[dict[str, Any]]: + """Return a bounded batch of pending desired exports.""" + return await self._fetch_all( + """ + SELECT * FROM nutrition_exports + WHERE status = 'pending' AND next_attempt <= ? + ORDER BY next_attempt, meal_id + LIMIT 10 + """, + (now,), + ) + + async def revisions(self, meal_id: int) -> list[dict[str, Any]]: + """Return all immutable revisions, including terminal states.""" + return await self._fetch_all( + """ + SELECT * FROM nutrition_revisions + WHERE meal_id = ? + ORDER BY sequence + """, + (meal_id,), + ) + + async def latest_payload(self, meal_id: int) -> dict[str, Any] | None: + """Return the newest decodable payload for preserving its interval.""" + rows = await self._fetch_all( + """ + SELECT payload_json FROM nutrition_revisions + WHERE meal_id = ? + ORDER BY sequence DESC + """, + (meal_id,), + ) + for row in rows: + try: + payload = json.loads(row["payload_json"]) + except (TypeError, json.JSONDecodeError): + continue + if isinstance(payload, dict): + return payload + return None + + async def revision_state(self, sequence: int, state: str) -> None: + """Record provider progress for one immutable revision. + + Keyed by ``sequence`` rather than ``resource_name``: a delete revision + deliberately reuses the resource name of the create it targets, so + the name alone cannot identify a single row. + """ + await self._conn.execute( + "UPDATE nutrition_revisions SET state = ? WHERE sequence = ?", + (state, sequence), + ) + + async def result( + self, + meal_id: int, + status: str, + *, + error: str | None = None, + next_attempt: float = 0, + expected_revision: str | None | object = _MISSING, + ) -> bool: + """Record a result without clobbering a newer desired edit. + + ``IS`` is used for the guard so deletion rows with a NULL desired + revision can be guarded as well. Omitting the guard preserves the + pre-feature calling convention. + """ + params: list[Any] = [status, error, next_attempt, meal_id] + where = "meal_id = ? AND status != 'cancelled'" + if expected_revision is not _MISSING: + if expected_revision is None: + where += " AND desired_revision IS NULL" + else: + where += " AND desired_revision IS ?" + params.append(expected_revision) + cursor = await self._conn.execute( + f""" + UPDATE nutrition_exports + SET status = ?, error_code = ?, attempts = attempts + 1, + next_attempt = ? + WHERE {where} + """, # noqa: S608 + tuple(params), + ) + return cursor.rowcount > 0 + + async def counts(self, user_id: str) -> dict[str, int]: + """Return durable non-cancelled export counts for one identity.""" + rows = await self._fetch_all( + """ + SELECT status, COUNT(*) AS count + FROM nutrition_exports + WHERE telegram_user_id = ? AND status != 'cancelled' + GROUP BY status + """, + (user_id,), + ) + return {str(row["status"]): int(row["count"]) for row in rows} + + async def cancel(self, user_id: str) -> None: + """Cancel dispatch and purge locally stored provider payloads/IDs.""" + await self._conn.execute( + """ + UPDATE nutrition_exports + SET status = 'cancelled', health_user_id = '', + desired_revision = NULL, error_code = NULL + WHERE telegram_user_id = ? + """, + (user_id,), + ) + await self._conn.execute( + """ + DELETE FROM nutrition_revisions + WHERE meal_id IN ( + SELECT meal_id FROM nutrition_exports WHERE telegram_user_id = ? + ) + """, + (user_id,), + ) + + async def resume(self, user_id: str, health_user_id: str) -> None: + """Resume only authorization-paused work for the same account.""" + await self._conn.execute( + """ + UPDATE nutrition_exports + SET status = 'pending', next_attempt = 0, error_code = NULL + WHERE telegram_user_id = ? AND health_user_id = ? + AND status = 'authorization_required' + """, + (user_id, health_user_id), + ) diff --git a/src/blacki/health/nutrition_worker.py b/src/blacki/health/nutrition_worker.py new file mode 100644 index 0000000..07aef70 --- /dev/null +++ b/src/blacki/health/nutrition_worker.py @@ -0,0 +1,447 @@ +"""Background dispatch and reconciliation of Google Health nutrition exports. + +Runs independently of the read-only health sync scheduler, once a minute. +Each tick advances at most one unresolved revision per due meal so ordering +between an earlier delete and a later create is always respected, and every +transient failure is retried with exponential backoff instead of ever being +dropped. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from datetime import UTC, datetime +from typing import Any + +from .client import GoogleHealthApiError, GoogleHealthAuthError, GoogleHealthClient +from .config import ( + GOOGLE_HEALTH_NUTRITION_SCOPES, + GoogleHealthConfig, + TokenEncryptionError, +) +from .storage import SqliteGoogleHealthStorage + +logger = logging.getLogger(__name__) + +_BASE_BACKOFF_SECONDS = 60.0 +_MAX_BACKOFF_SECONDS = 3600.0 +_POLL_INTERVAL_SECONDS = 60.0 +_TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS = 60.0 +_TERMINAL_REVISION_STATES = {"synced", "deleted", "failed", "cancelled"} + + +class NutritionExportWorker: + """Dispatch pending nutrition export jobs and reconcile ambiguous ones.""" + + def __init__( + self, + config: GoogleHealthConfig, + storage: SqliteGoogleHealthStorage, + *, + client: GoogleHealthClient | None = None, + ) -> None: + self.config = config + self.storage = storage + self.client = client or GoogleHealthClient(config) + self._running = False + self._task: asyncio.Task[None] | None = None + self._wake_event = asyncio.Event() + self._token_cache: dict[str, tuple[str, float]] = {} + + async def start(self) -> None: + """Start the dispatch loop: every 60s, or immediately on ``wake()``.""" + if self._running: + return + self._running = True + self._task = asyncio.create_task( + self._run_loop(), name="google_health_nutrition_export" + ) + logger.info("Google Health nutrition export worker started") + + async def stop(self) -> None: + """Stop the dispatch loop and wait for a running tick to finish.""" + if not self._running: + return + self._running = False + self._wake_event.set() + if self._task is not None: + await self._task + self._task = None + logger.info("Google Health nutrition export worker stopped") + + async def close(self) -> None: + """Close the owned Google Health HTTP client.""" + await self.client.close() + + def wake(self) -> None: + """Trigger an immediate dispatch tick instead of waiting for the timer. + + Safe to call from ``MealService.mutate()`` right after its commit: + it only sets a flag the single dispatch loop already checks, so it + cannot race with or duplicate a tick already in progress. + """ + self._wake_event.set() + + async def _run_loop(self) -> None: + while True: + with contextlib.suppress(TimeoutError): + await asyncio.wait_for( + self._wake_event.wait(), timeout=_POLL_INTERVAL_SECONDS + ) + self._wake_event.clear() + if not self._running: + return + await self._dispatch_due() + + async def _dispatch_due(self) -> None: + """Process one bounded batch of due export jobs, isolating failures.""" + try: + due = await self.storage.nutrition.due(datetime.now(UTC).timestamp()) + except Exception: + logger.exception("Failed to load due Google Health export jobs") + return + for row in due: + try: + await self._process_meal(row) + except Exception: + logger.exception("Google Health export job failed unexpectedly") + + async def _process_meal(self, row: dict[str, Any]) -> None: + meal_id = int(row["meal_id"]) + telegram_user_id = str(row["telegram_user_id"]) + health_user_id = str(row["health_user_id"]) + attempts = int(row["attempts"]) + desired_revision = row["desired_revision"] + desired_operation = row["desired_operation"] + + nutrition = self.storage.nutrition + revisions = await nutrition.revisions(meal_id) + pending, stale = _select_pending_revision(revisions, desired_revision) + for revision in stale: + await nutrition.revision_state(int(revision["sequence"]), "cancelled") + + if pending is None: + await nutrition.result( + meal_id, + _final_status(desired_operation), + expected_revision=desired_revision, + ) + return + + connection = await self.storage.get_connection(telegram_user_id) + if ( + connection is None + or connection.status != "connected" + or connection.encrypted_refresh_token is None + or connection.health_user_id != health_user_id + ): + await self.storage.mark_reauthorization_required(telegram_user_id) + return + + if not set(GOOGLE_HEALTH_NUTRITION_SCOPES) <= set(connection.scopes): + # The connection itself is fine, only nutrition write scope is + # missing (e.g. the user reconnected with read-only scopes). + # Pause just this export instead of nuking the whole connection, + # which would also disable read-only Health summaries. + await nutrition.result( + meal_id, + "authorization_required", + error="nutrition_scope_missing", + expected_revision=desired_revision, + ) + return + + try: + refresh_token = self.config.cipher.decrypt( + connection.encrypted_refresh_token + ) + except TokenEncryptionError: + await self.storage.mark_reauthorization_required( + telegram_user_id, "stored_token_invalid" + ) + return + + try: + access_token = await self._get_access_token(health_user_id, refresh_token) + except GoogleHealthAuthError as exc: + self._token_cache.pop(health_user_id, None) + await self.storage.mark_reauthorization_required( + telegram_user_id, exc.error_code or "authorization_required" + ) + return + except GoogleHealthApiError as exc: + await self._backoff(nutrition, meal_id, attempts, desired_revision, exc) + return + + try: + if str(pending["operation"]) == "upsert": + outcome, error = await self._dispatch_upsert( + nutrition, access_token, pending + ) + else: + outcome, error = await self._dispatch_delete( + nutrition, access_token, pending + ) + except GoogleHealthAuthError as exc: + self._token_cache.pop(health_user_id, None) + await self.storage.mark_reauthorization_required( + telegram_user_id, exc.error_code or "authorization_required" + ) + return + + if outcome == "resolved": + remaining = await nutrition.revisions(meal_id) + still_pending, _ = _select_pending_revision(remaining, desired_revision) + if still_pending is None: + await nutrition.result( + meal_id, + _final_status(desired_operation), + expected_revision=desired_revision, + ) + else: + await nutrition.result( + meal_id, + "pending", + next_attempt=0, + expected_revision=desired_revision, + ) + elif outcome == "failed": + safe_error = _safe_error_code(error) + await nutrition.result( + meal_id, + "failed", + error=safe_error, + expected_revision=desired_revision, + ) + logger.warning( + "Google Health nutrition export failed permanently: error_code=%s", + safe_error, + ) + else: + await self._backoff(nutrition, meal_id, attempts, desired_revision, error) + + async def _get_access_token(self, health_user_id: str, refresh_token: str) -> str: + """Return a cached access token, refreshing only on miss or expiry. + + Cached per Google account so a batch of due meals for the same user + costs one token-endpoint call instead of one per meal. Never + persisted: it is cheap to re-derive from the encrypted-at-rest + refresh token, and a lost cache on restart costs one extra refresh. + """ + now = datetime.now(UTC).timestamp() + cached = self._token_cache.get(health_user_id) + if cached is not None and cached[1] > now: + return cached[0] + + token = await self.client.refresh_access_token(refresh_token) + expires_in = token.expires_in if token.expires_in is not None else 0 + expires_at = now + max(0.0, expires_in - _TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS) + self._token_cache[health_user_id] = (token.access_token, expires_at) + return token.access_token + + async def _dispatch_upsert( + self, nutrition: Any, access_token: str, revision: dict[str, Any] + ) -> tuple[str, Exception | None]: + sequence = int(revision["sequence"]) + resource_name = str(revision["resource_name"]) + if str(revision["state"]) in {"uncertain", "in_flight"}: + # A crash between marking "in_flight" and recording the create's + # outcome leaves that state persisted across restarts, not just + # set in memory. Reconcile it the same way as "uncertain" instead + # of blindly re-POSTing a create that may have already landed. + return await self._verify_upsert(nutrition, access_token, revision) + + payload = json.loads(revision["payload_json"]) + await nutrition.revision_state(sequence, "in_flight") + try: + operation = await self.client.create_nutrition_log( + access_token, resource_name, payload + ) + except GoogleHealthAuthError: + raise + except GoogleHealthApiError as exc: + if _is_transient(exc): + await nutrition.revision_state(sequence, "uncertain") + return "retry", exc + await nutrition.revision_state(sequence, "failed") + return "failed", exc + + if operation.successful: + await nutrition.revision_state(sequence, "synced") + return "resolved", None + if not operation.done: + await nutrition.revision_state(sequence, "uncertain") + return "retry", None + await nutrition.revision_state(sequence, "failed") + return "failed", _ProviderError(operation.error_code) + + async def _verify_upsert( + self, nutrition: Any, access_token: str, revision: dict[str, Any] + ) -> tuple[str, Exception | None]: + sequence = int(revision["sequence"]) + resource_name = str(revision["resource_name"]) + try: + point = await self.client.get_data_point(access_token, resource_name) + except GoogleHealthAuthError: + raise + except GoogleHealthApiError as exc: + if exc.status_code == 404: + await nutrition.revision_state(sequence, "queued") + return "retry", None + if _is_transient(exc): + return "retry", exc + await nutrition.revision_state(sequence, "failed") + return "failed", exc + + payload = json.loads(revision["payload_json"]) + if _nutrition_log_matches(point, payload): + await nutrition.revision_state(sequence, "synced") + return "resolved", None + await nutrition.revision_state(sequence, "failed") + return "failed", _ProviderError("verification_mismatch") + + async def _dispatch_delete( + self, nutrition: Any, access_token: str, revision: dict[str, Any] + ) -> tuple[str, Exception | None]: + sequence = int(revision["sequence"]) + resource_name = str(revision["resource_name"]) + if str(revision["state"]) in {"uncertain", "in_flight"}: + # Same restart hazard as the upsert path: a persisted "in_flight" + # delete must be reconciled with a GET before retrying, since the + # delete itself may have already succeeded before the crash. + try: + await self.client.get_data_point(access_token, resource_name) + except GoogleHealthAuthError: + raise + except GoogleHealthApiError as exc: + if exc.status_code == 404: + await nutrition.revision_state(sequence, "deleted") + return "resolved", None + if _is_transient(exc): + return "retry", exc + await nutrition.revision_state(sequence, "failed") + return "failed", exc + await nutrition.revision_state(sequence, "queued") + return "retry", None + + await nutrition.revision_state(sequence, "in_flight") + try: + operation = await self.client.delete_nutrition_log( + access_token, resource_name + ) + except GoogleHealthAuthError: + raise + except GoogleHealthApiError as exc: + if exc.status_code == 404: + await nutrition.revision_state(sequence, "deleted") + return "resolved", None + if _is_transient(exc): + await nutrition.revision_state(sequence, "uncertain") + return "retry", exc + await nutrition.revision_state(sequence, "failed") + return "failed", exc + + if operation.successful: + await nutrition.revision_state(sequence, "deleted") + return "resolved", None + if not operation.done: + await nutrition.revision_state(sequence, "uncertain") + return "retry", None + await nutrition.revision_state(sequence, "failed") + return "failed", _ProviderError(operation.error_code) + + async def _backoff( + self, + nutrition: Any, + meal_id: int, + attempts: int, + expected_revision: str | None, + error: Exception | None, + ) -> None: + delay = min(_BASE_BACKOFF_SECONDS * (2**attempts), _MAX_BACKOFF_SECONDS) + retry_after = getattr(error, "retry_after_seconds", None) + if isinstance(retry_after, int | float): + delay = max(delay, min(float(retry_after), _MAX_BACKOFF_SECONDS)) + next_attempt = datetime.now(UTC).timestamp() + delay + await nutrition.result( + meal_id, + "pending", + error=_safe_error_code(error), + next_attempt=next_attempt, + expected_revision=expected_revision, + ) + + +class _ProviderError(Exception): + """Wrap a safe Google Health operation error code for uniform handling.""" + + def __init__(self, error_code: str | None) -> None: + super().__init__(error_code or "provider_error") + self.error_code = error_code or "provider_error" + + +def _final_status(desired_operation: Any) -> str: + return "deleted" if str(desired_operation) == "delete" else "synced" + + +def _select_pending_revision( + revisions: list[dict[str, Any]], desired_revision: str | None +) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + """Pick the oldest unresolved revision, cancelling never-dispatched creates. + + An ``upsert`` revision whose resource name no longer matches the meal's + current desired target was superseded by a later edit or deletion. If it + was never sent to Google (``queued``), it is safe to drop outright — but + an ``in_flight``/``uncertain`` one must be reconciled first, regardless of + its position, since Google may already be processing it. + + A ``delete`` revision is never treated as stale by this check: its + resource name is always the *old* point being removed, not the meal's + current desired target, so it would otherwise always look superseded. + Skipping a real, still-queued delete would leave the old point on Google + forever once its replacement create lands. + """ + non_terminal = [ + r for r in revisions if str(r["state"]) not in _TERMINAL_REVISION_STATES + ] + stale: list[dict[str, Any]] = [] + for revision in non_terminal: + if ( + str(revision["operation"]) == "upsert" + and str(revision["resource_name"]) != desired_revision + and str(revision["state"]) == "queued" + ): + stale.append(revision) + continue + return revision, stale + return None, stale + + +def _is_transient(exc: GoogleHealthApiError) -> bool: + if exc.transport or exc.status_code is None: + return True + return exc.status_code == 429 or exc.status_code >= 500 + + +def _safe_error_code(error: Exception | None) -> str | None: + if error is None: + return None + code = getattr(error, "error_code", None) + if isinstance(code, str) and code.isascii() and code.isprintable(): + return code[:80] + return type(error).__name__ + + +def _nutrition_log_matches(point: dict[str, Any], payload: dict[str, Any]) -> bool: + remote = point.get("nutritionLog") + intended = payload.get("nutritionLog") + if not isinstance(remote, dict) or not isinstance(intended, dict): + return False + if remote.get("foodDisplayName") != intended.get("foodDisplayName"): + return False + remote_energy = remote.get("energy") or {} + intended_energy = intended.get("energy") or {} + return remote_energy.get("kcal") == intended_energy.get("kcal") diff --git a/src/blacki/health/service.py b/src/blacki/health/service.py index 58e9426..354f809 100644 --- a/src/blacki/health/service.py +++ b/src/blacki/health/service.py @@ -65,6 +65,7 @@ class SyncResult: records_fetched: int = 0 unavailable_data_types: tuple[str, ...] = () next_allowed_at: str | None = None + google_health_sync: dict[str, int] | None = None class GoogleHealthService: @@ -171,6 +172,23 @@ async def disconnect(self, telegram_user_id: str) -> bool: logger.warning("Google Health remote token revocation failed") return await self.storage.delete_connection(user_id) + async def _sync_result( + self, *, status: str, telegram_user_id: str, **fields: Any + ) -> SyncResult: + """Build a ``SyncResult`` carrying durable meal-sync counts. + + Every caller reports these counts regardless of provider outcome, so + Telegram can show pending/synced/failed meal exports even when the + health-data sync itself failed or the account needs reauthorization. + """ + counts = await self.storage.nutrition.counts(telegram_user_id) + return SyncResult( + status=status, + telegram_user_id=telegram_user_id, + google_health_sync=counts, + **fields, + ) + async def refresh_user( self, telegram_user_id: str, *, days: int = 14 ) -> SyncResult: @@ -183,8 +201,10 @@ async def refresh_user( if not allowed: status = await self.connection_status(user_id) if status["status"] != "connected": - return SyncResult(status=status["status"], telegram_user_id=user_id) - return SyncResult( + return await self._sync_result( + status=status["status"], telegram_user_id=user_id + ) + return await self._sync_result( status="rate_limited", telegram_user_id=user_id, next_allowed_at=next_allowed_at.isoformat() @@ -198,12 +218,16 @@ async def sync_user(self, telegram_user_id: str, *, days: int = 14) -> SyncResul user_id = _canonical_user_id(telegram_user_id) connection = await self.storage.get_connection(user_id) if connection is None: - return SyncResult(status="not_connected", telegram_user_id=user_id) + return await self._sync_result( + status="not_connected", telegram_user_id=user_id + ) if ( connection.status != "connected" or connection.encrypted_refresh_token is None ): - return SyncResult(status=connection.status, telegram_user_id=user_id) + return await self._sync_result( + status=connection.status, telegram_user_id=user_id + ) try: refresh_token = self.config.cipher.decrypt( @@ -213,7 +237,7 @@ async def sync_user(self, telegram_user_id: str, *, days: int = 14) -> SyncResul await self.storage.mark_reauthorization_required( user_id, "stored_token_invalid" ) - return SyncResult( + return await self._sync_result( status="reauthorization_required", telegram_user_id=user_id ) @@ -229,7 +253,7 @@ async def sync_user(self, telegram_user_id: str, *, days: int = 14) -> SyncResul await self.storage.mark_reauthorization_required( user_id, exc.error_code or "authorization_required" ) - return SyncResult( + return await self._sync_result( status="reauthorization_required", telegram_user_id=user_id ) except GoogleHealthApiError as exc: @@ -237,7 +261,7 @@ async def sync_user(self, telegram_user_id: str, *, days: int = 14) -> SyncResul await self.storage.mark_reauthorization_required( user_id, "invalid_grant" ) - return SyncResult( + return await self._sync_result( status="reauthorization_required", telegram_user_id=user_id ) logger.warning( @@ -245,7 +269,7 @@ async def sync_user(self, telegram_user_id: str, *, days: int = 14) -> SyncResul exc.status_code, exc.error_code, ) - return SyncResult(status="failed", telegram_user_id=user_id) + return await self._sync_result(status="failed", telegram_user_id=user_id) start_time, end_time = _sync_window(days) data_by_type: dict[str, list[dict[str, Any]]] = {} @@ -273,7 +297,7 @@ async def sync_user(self, telegram_user_id: str, *, days: int = 14) -> SyncResul await self.storage.mark_reauthorization_required( user_id, exc.error_code or "authorization_required" ) - return SyncResult( + return await self._sync_result( status="reauthorization_required", telegram_user_id=user_id ) except GoogleHealthApiError as exc: @@ -284,7 +308,9 @@ async def sync_user(self, telegram_user_id: str, *, days: int = 14) -> SyncResul exc.status_code, exc.error_code, ) - return SyncResult(status="failed", telegram_user_id=user_id) + return await self._sync_result( + status="failed", telegram_user_id=user_id + ) data_by_type[data_type] = points records_fetched += len(points) @@ -303,7 +329,7 @@ async def sync_user(self, telegram_user_id: str, *, days: int = 14) -> SyncResul len(normalized_days), len(unavailable), ) - return SyncResult( + return await self._sync_result( status="success", telegram_user_id=user_id, days_upserted=len(normalized_days), diff --git a/src/blacki/health/storage.py b/src/blacki/health/storage.py index 9ae8384..9b79f20 100644 --- a/src/blacki/health/storage.py +++ b/src/blacki/health/storage.py @@ -2,17 +2,20 @@ from __future__ import annotations +import asyncio import json from collections.abc import Mapping from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any +from typing import Any + +import aiosqlite from blacki.storage.base import SqlStorage from blacki.utils.timezone import now_utc -if TYPE_CHECKING: - pass +from .config import GOOGLE_HEALTH_NUTRITION_SCOPES +from .nutrition_storage import NutritionStorage @dataclass(frozen=True, slots=True) @@ -34,6 +37,10 @@ class HealthConnection: class SqliteGoogleHealthStorage(SqlStorage): """Store encrypted credentials, OAuth state, and normalized daily records.""" + def __init__(self, conn: aiosqlite.Connection, lock: asyncio.Lock) -> None: + super().__init__(conn, lock) + self._nutrition = NutritionStorage(conn, lock) + async def _create_tables(self) -> None: await self._conn.execute(""" CREATE TABLE IF NOT EXISTS google_health_connections ( @@ -72,6 +79,21 @@ async def _create_tables(self) -> None: ON google_health_oauth_states (expires_at) """ ) + # This is called while the health storage owns the shared write lock. + # Calling NutritionStorage.initialize() here would deadlock on that lock. + await self._nutrition._create_tables() + self._nutrition._schema_ready = True + + @property + def nutrition(self) -> NutritionStorage: + """Return durable meal export storage on this same connection.""" + return self._nutrition + + async def close(self) -> None: + """Reset both health schemas without acquiring the lock twice.""" + async with self._lock: + self._schema_ready = False + self._nutrition._schema_ready = False async def store_oauth_state( self, @@ -153,6 +175,7 @@ async def upsert_connection( "WHERE telegram_user_id = ?", (telegram_user_id,), ) + await self._nutrition.cancel(telegram_user_id) await self._conn.execute( """ @@ -191,6 +214,10 @@ async def upsert_connection( connected_at, ), ) + if not identity_changed and set(GOOGLE_HEALTH_NUTRITION_SCOPES) <= set( + scopes + ): + await self._nutrition.resume(telegram_user_id, health_user_id) await self._conn.execute("COMMIT") except Exception: await self._conn.execute("ROLLBACK") @@ -236,7 +263,9 @@ async def mark_synced(self, telegram_user_id: str) -> None: ) async def mark_reauthorization_required( - self, telegram_user_id: str, error_code: str = "authorization_required" + self, + telegram_user_id: str, + error_code: str = "authorization_required", ) -> None: """Disable scheduled pulls while retaining only safe status metadata.""" safe_error = ( @@ -257,6 +286,14 @@ async def mark_reauthorization_required( """, (safe_error, telegram_user_id), ) + await self._conn.execute( + """ + UPDATE nutrition_exports + SET status = 'authorization_required', error_code = ? + WHERE telegram_user_id = ? AND status = 'pending' + """, + (safe_error, telegram_user_id), + ) async def claim_manual_refresh( self, @@ -402,19 +439,27 @@ async def get_daily_summaries( async def delete_connection(self, telegram_user_id: str) -> bool: """Delete credentials, OAuth state, and normalized health data.""" async with self._lock: - cursor = await self._conn.execute( - "DELETE FROM google_health_connections WHERE telegram_user_id = ?", - (telegram_user_id,), - ) - await self._conn.execute( - "DELETE FROM google_health_oauth_states WHERE telegram_user_id = ?", - (telegram_user_id,), - ) - await self._conn.execute( - "DELETE FROM google_health_daily_summaries WHERE telegram_user_id = ?", - (telegram_user_id,), - ) - return cursor.rowcount > 0 + await self._conn.execute("BEGIN") + try: + await self._nutrition.cancel(telegram_user_id) + cursor = await self._conn.execute( + "DELETE FROM google_health_connections WHERE telegram_user_id = ?", + (telegram_user_id,), + ) + await self._conn.execute( + "DELETE FROM google_health_oauth_states WHERE telegram_user_id = ?", + (telegram_user_id,), + ) + await self._conn.execute( + "DELETE FROM google_health_daily_summaries " + "WHERE telegram_user_id = ?", + (telegram_user_id,), + ) + await self._conn.execute("COMMIT") + return cursor.rowcount > 0 + except Exception: + await self._conn.execute("ROLLBACK") + raise def _connection_from_row(row: Mapping[str, Any]) -> HealthConnection: diff --git a/src/blacki/privacy.py b/src/blacki/privacy.py index 86d90c0..91bc9bb 100644 --- a/src/blacki/privacy.py +++ b/src/blacki/privacy.py @@ -14,6 +14,11 @@ _ZEPTO_TOOL_PREFIX = "zepto_" _PRIVATE_TOOL_NAMES = frozenset( { + "log_meal", + "edit_meal", + "delete_meal", + "get_calorie_summary", + "set_calorie_goal", "get_health_summary", "send_text_to_speech", "list_user_files", diff --git a/src/blacki/prompt.py b/src/blacki/prompt.py index a7343b8..385f649 100644 --- a/src/blacki/prompt.py +++ b/src/blacki/prompt.py @@ -68,6 +68,17 @@ when it would make the estimate misleading. Preserve an explicit or relative meal date in the tool call; never replace an invalid date with today. Use only breakfast, lunch, dinner, or snack as meal types. + +After log_meal, edit_meal, or delete_meal succeeds locally, report that local +result and the returned google_health_sync status separately. A successful +log or edit remains saved in Blacki even when that status is pending, failed, +not_enabled, or authorization_required; a successful delete remains deleted +locally in those states. Never claim Google Health accepted a change unless the +status says synced. Do not repeat a meal mutation just because remote export +failed or is still pending, because that can create a duplicate local meal. +Only eligible future meals from a private Telegram chat are exported after the +user grants both nutrition permissions. There is no historical backfill, and +missing nutrition values are omitted rather than invented. """ @@ -106,14 +117,17 @@ HEALTH_POLICY = """\ -Google Health is a read-only wellness summary source. Use get_health_summary -only for the authenticated user's private Telegram data. Never request Apple ID -credentials, Fitbit credentials, raw provider payloads, ECG data, medication or -clinical records, or another user's health information. Omit missing metrics; -never infer, diagnose, or present wellness observations as medical advice. The -Apple Health import path is user-configured and may be incomplete, so describe -the source as Google Health and explain that absence does not prove absence in -Apple Health. +Google Health is a read-only wellness summary source. Use +get_health_summary only for the authenticated user's private Telegram data. +Meal export is a separate, optional write capability that applies only after +the user grants both Google Health nutrition permissions. Never request Apple +ID credentials, Fitbit credentials, raw provider payloads, ECG data, medication +or clinical records, or another user's health information. Omit missing +metrics; never infer, diagnose, or present wellness observations as medical +advice. The Apple Health import path is user-configured and may be incomplete, +so describe the source as Google Health and explain that absence does not prove +absence in Apple Health. Keep local Blacki save status distinct from remote +Google Health sync status. """ diff --git a/src/blacki/server.py b/src/blacki/server.py index 0c4c7c8..fc84f05 100644 --- a/src/blacki/server.py +++ b/src/blacki/server.py @@ -50,17 +50,22 @@ _container: AppContainer | None = None _google_health_service = None _google_health_scheduler = None +_google_health_export_worker = None async def _start_google_health() -> None: - """Initialize the optional Google Health connector and its scheduler.""" - global _google_health_scheduler, _google_health_service + """Initialize the optional Google Health connector and its schedulers.""" + global \ + _google_health_scheduler, \ + _google_health_service, \ + _google_health_export_worker if _container is None: logger.info("Google Health connector not started (no container)") return from .health.config import GoogleHealthConfig, GoogleHealthConfigurationError + from .health.nutrition_worker import NutritionExportWorker from .health.scheduler import GoogleHealthScheduler from .health.service import GoogleHealthService @@ -81,8 +86,21 @@ async def _start_google_health() -> None: logger.exception("Google Health scheduler failed to start") await service.close() return + + export_worker = NutritionExportWorker(config, _container.google_health_storage) + try: + await export_worker.start() + except Exception: + logger.exception("Google Health nutrition export worker failed to start") + await scheduler.stop() + await export_worker.close() + await service.close() + return + _google_health_service = service _google_health_scheduler = scheduler + _google_health_export_worker = export_worker + _container.nutrition_export_worker = export_worker logger.info("Google Health connector initialized") @@ -176,9 +194,20 @@ async def _stop_reminder_scheduler() -> None: async def _stop_google_health() -> None: - """Stop the optional health scheduler and close its HTTP client.""" - global _google_health_scheduler, _google_health_service + """Stop the optional health schedulers and close their HTTP clients.""" + global \ + _google_health_scheduler, \ + _google_health_service, \ + _google_health_export_worker + if _google_health_export_worker is not None: + if _container is not None: + _container.nutrition_export_worker = None + try: + await _google_health_export_worker.stop() + await _google_health_export_worker.close() + except Exception: + logger.exception("Error stopping Google Health nutrition export worker") if _google_health_scheduler is not None: try: await _google_health_scheduler.stop() @@ -191,6 +220,7 @@ async def _stop_google_health() -> None: logger.exception("Error closing Google Health client") _google_health_scheduler = None _google_health_service = None + _google_health_export_worker = None AGENT_DIR = os.getenv("AGENT_DIR", str(Path(__file__).resolve().parent.parent)) diff --git a/src/blacki/telegram/bot.py b/src/blacki/telegram/bot.py index 65282cc..8442c25 100644 --- a/src/blacki/telegram/bot.py +++ b/src/blacki/telegram/bot.py @@ -6,7 +6,7 @@ import hmac import logging import re -from collections.abc import Coroutine, Sequence +from collections.abc import Coroutine, Mapping, Sequence from contextvars import ContextVar from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol, cast @@ -72,22 +72,56 @@ _MAX_ALBUM_BYTES = 20 * 1024 * 1024 +def _format_google_health_sync_counts(value: object) -> str: + """Render safe durable meal-sync counts without provider details.""" + if not isinstance(value, Mapping): + return "" + + labels = ( + ("pending", "pending"), + ("synced", "synced"), + ("failed", "failed"), + ("authorization_required", "awaiting authorization"), + ) + parts: list[str] = [] + for key, label in labels: + count = value.get(key) + if isinstance(count, int) and count >= 0: + parts.append(f"{count} {label}") + if not parts: + return "" + return "Meal sync status (pending includes deletions): " + ", ".join(parts) + + def _format_health_sync_result(result: SyncResult) -> str: """Render a provider-sync result without exposing IDs or error payloads.""" if result.status == "not_connected": - return "Google Health is not connected. Use /connect_health first." + text = "Google Health is not connected. Use /connect_health first." + return _append_google_health_sync_counts(text, result) if result.status == "reauthorization_required": - return ( + text = ( "Google Health needs authorization again. Use /connect_health to reconnect." ) + return _append_google_health_sync_counts(text, result) if result.status == "rate_limited": - return "A Google Health refresh was requested recently. Please try again later." + text = "A Google Health refresh was requested recently. Please try again later." + return _append_google_health_sync_counts(text, result) if result.status == "success": - return ( + text = ( f"Google Health refreshed {result.days_upserted} day(s) from " f"{result.records_fetched} record(s)." ) - return "Google Health could not be refreshed right now. Please try again later." + return _append_google_health_sync_counts(text, result) + text = "Google Health could not be refreshed right now. Please try again later." + return _append_google_health_sync_counts(text, result) + + +def _append_google_health_sync_counts(text: str, result: object) -> str: + """Append durable meal-sync counts when the health service provides them.""" + counts = _format_google_health_sync_counts( + getattr(result, "google_health_sync", None) + ) + return f"{text}\n{counts}" if counts else text @dataclass(slots=True, frozen=True) @@ -221,7 +255,7 @@ async def _register_commands(self) -> None: [ BotCommand( command="connect_health", - description="Connect Google Health read-only data", + description="Connect Google Health and meal sync", ), BotCommand( command="health_summary", @@ -233,7 +267,7 @@ async def _register_commands(self) -> None: ), BotCommand( command="disconnect_health", - description="Disconnect and delete Google Health data", + description="Disconnect Google Health and meal sync", ), ] ) @@ -1127,11 +1161,17 @@ async def _connect_health(self, message: Message) -> None: await self.api.send_message( chat_id=message.chat.id, text=( - "Google Health connection is read-only. It can summarize " + "Google Health can provide read-only wellness summaries from " "data that reaches Google Health from Fitbit-compatible sources. " + "If you grant both Google Health nutrition permissions, Blacki " + "will automatically export future meal logs, edits, and " + "deletions from this private chat. The nutrition read permission " + "also lets Blacki verify records it created; it does not import " + "unrelated food logs. Older meals are not backfilled. " + "Read-only summaries remain available without nutrition " + "permissions. Existing connections must reconnect to add them. " "Blacki does not receive Apple ID credentials or raw Apple Health " - "records. Authorize only if you want this private chat to read " - "the selected health categories." + "records. Authorize only if you want these selected categories." ), message_thread_id=message.message_thread_id, reply_markup=InlineKeyboardMarkup( @@ -1183,7 +1223,9 @@ async def _refresh_health(self, message: Message) -> None: result = await service.refresh_user(f"telegram-chat-{message.chat.id}") if result.status == "success": summary = await service.summary(f"telegram-chat-{message.chat.id}") - text = format_health_summary(summary) + text = _append_google_health_sync_counts( + format_health_summary(summary), result + ) else: text = _format_health_sync_result(result) await self._send_health_text(message, text) @@ -1200,21 +1242,24 @@ async def _refresh_health(self, message: Message) -> None: ) async def _request_health_disconnect(self, message: Message) -> None: - """Ask for a final Telegram click before deleting local health data.""" + """Ask for a final Telegram click before disconnecting health sync.""" if not await self._health_command_ready(message): return await self.api.send_message( chat_id=message.chat.id, text=( - "Disconnect Google Health and delete Blacki's stored health " - "data for this chat? This cannot be undone locally." + "Disconnect Google Health and cancel future meal sync for this " + "chat? Blacki will remove its stored health credentials and " + "summaries, but keep local calorie logs. Blacki will not delete " + "records already sent to Google Health; requests already submitted " + "may still finish." ), message_thread_id=message.message_thread_id, reply_markup=InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text="Disconnect and delete data", + text="Disconnect and cancel sync", callback_data="health:disconnect", ), InlineKeyboardButton( @@ -1274,7 +1319,9 @@ async def _handle_health_callback(self, query: CallbackQuery) -> None: f"telegram-chat-{chat.id}" ) text = ( - "Google Health was disconnected and stored health data was deleted." + "Google Health was disconnected. Pending meal sync was cancelled, " + "local calorie logs remain, and Blacki did not delete records already " + "sent to Google Health. Requests already submitted may still finish." if deleted else "Google Health was already disconnected." ) @@ -1295,7 +1342,9 @@ async def notify_health_connection( return text = ( "Google Health is connected. Use /health_refresh for a fresh sync or " - "/health_summary to read the latest stored records." + "/health_summary to read the latest stored records. If both nutrition " + "permissions were granted, future private meal logs, edits, and " + "deletions will sync automatically; older meals are not backfilled." if connected else ( "Google Health authorization was cancelled. No credentials were stored." @@ -1336,10 +1385,10 @@ async def _send_start_message(self, chat_id: int) -> None: if self.google_health_service is not None: health_commands = ( "\n" - "/connect_health - Connect Google Health read-only data\n" + "/connect_health - Connect Google Health and optional meal sync\n" "/health_summary - Show the latest health summary\n" "/health_refresh - Refresh health data\n" - "/disconnect_health - Disconnect and delete health data" + "/disconnect_health - Disconnect and cancel meal sync" ) text = escape_markdown_plain( "👋 Hello! I'm blacki, your AI assistant.\n\n" @@ -1366,10 +1415,10 @@ async def _send_help_message(self, chat_id: int) -> None: health_commands = "" if self.google_health_service is not None: health_commands = ( - "• /connect_health \\- Connect Google Health read-only data\n" + "• /connect_health \\- Connect Google Health and optional meal sync\n" "• /health_summary \\- Show the latest health summary\n" "• /health_refresh \\- Refresh health data\n" - "• /disconnect_health \\- Disconnect and delete health data\n" + "• /disconnect_health \\- Disconnect and cancel meal sync\n" ) text = ( "🤖 *blacki \\- AI Assistant*\n\n" diff --git a/tests/calories/test_service.py b/tests/calories/test_service.py new file mode 100644 index 0000000..a0a246d --- /dev/null +++ b/tests/calories/test_service.py @@ -0,0 +1,603 @@ +"""Tests for atomic meal mutations and Google Health export enrollment.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock + +import aiosqlite +import pytest +from cryptography.fernet import Fernet + +from blacki.calories.service import ( + MealService, + get_meal_service, + nutrition_payload, + validate_meal, +) +from blacki.calories.storage import CalorieEntry +from blacki.container import ( + AppContainer, + reset_container_for_tests, + set_container, +) +from blacki.health.config import GOOGLE_HEALTH_NUTRITION_SCOPES, GoogleHealthConfig + +USER_ID = "telegram-chat-500" +OTHER_HEALTH_USER_ID = "telegram-chat-999" + + +def _entry(**overrides: object) -> CalorieEntry: + fields: dict[str, object] = { + "user_id": USER_ID, + "description": "Oatmeal", + "calories": 300, + "logged_at": "2026-01-05T08:00:00+00:00", + "logged_date": "2026-01-05", + } + fields.update(overrides) + return CalorieEntry.model_validate(fields) + + +@pytest.fixture +async def container() -> AsyncGenerator[AppContainer, None]: + conn = await aiosqlite.connect(":memory:", isolation_level=None) + conn.row_factory = aiosqlite.Row + app_container = AppContainer(conn=conn) + await app_container.initialize_all_storages() + yield app_container + await app_container.close() + + +async def _connect( + container: AppContainer, + *, + telegram_user_id: str = USER_ID, + health_user_id: str = USER_ID, + scopes: tuple[str, ...] = GOOGLE_HEALTH_NUTRITION_SCOPES, +) -> None: + key = Fernet.generate_key().decode() + config = GoogleHealthConfig( + client_id="client-id", + client_secret="client-secret", + redirect_uri="https://example.test/integrations/google-health/callback", + token_encryption_key=key, + sync_interval_hours=12, + manual_refresh_cooldown_seconds=3600, + oauth_state_ttl_seconds=600, + ) + await container.google_health_storage.upsert_connection( + telegram_user_id=telegram_user_id, + encrypted_refresh_token=config.cipher.encrypt("refresh-token"), + health_user_id=health_user_id, + legacy_fitbit_user_id=None, + scopes=scopes, + ) + + +# --- validate_meal ----------------------------------------------------- + + +def test_validate_meal_rejects_empty_description() -> None: + with pytest.raises(ValueError, match="description cannot be empty"): + validate_meal(_entry(description=" ")) + + +def test_validate_meal_rejects_nonpositive_calories() -> None: + with pytest.raises(ValueError, match="estimated_calories must be > 0"): + validate_meal(_entry(calories=0)) + + +def test_validate_meal_rejects_unknown_meal_type() -> None: + with pytest.raises(ValueError, match="meal_type must be"): + validate_meal(_entry(meal_type="brunch")) + + +def test_validate_meal_accepts_none_meal_type() -> None: + validate_meal(_entry(meal_type=None)) + + +@pytest.mark.parametrize("field", ["protein_g", "carbs_g", "fat_g"]) +def test_validate_meal_rejects_negative_macros(field: str) -> None: + with pytest.raises(ValueError, match="macros must be finite and nonnegative"): + validate_meal(_entry(**{field: -1.0})) + + +@pytest.mark.parametrize("field", ["protein_g", "carbs_g", "fat_g"]) +def test_validate_meal_rejects_nonfinite_macros(field: str) -> None: + with pytest.raises(ValueError, match="macros must be finite and nonnegative"): + validate_meal(_entry(**{field: float("nan")})) + + +# --- nutrition_payload --------------------------------------------------- + + +def test_nutrition_payload_maps_core_fields() -> None: + entry = _entry( + meal_type="breakfast", + protein_g=10.0, + carbs_g=20.0, + fat_g=5.0, + logged_at="2026-01-05T08:00:00+00:00", + logged_date="2026-01-05", + ) + payload = nutrition_payload(entry)["nutritionLog"] + assert payload["foodDisplayName"] == "Oatmeal" + assert payload["energy"] == {"kcal": 300} + assert payload["mealType"] == "BREAKFAST" + assert payload["nutrients"] == [ + {"nutrient": "PROTEIN", "quantity": {"grams": 10.0}} + ] + assert payload["totalCarbohydrate"] == {"grams": 20.0} + assert payload["totalFat"] == {"grams": 5.0} + assert payload["interval"]["startTime"] == "2026-01-05T08:00:00Z" + + +def test_nutrition_payload_omits_absent_optional_fields() -> None: + entry = _entry(meal_type=None, protein_g=None, carbs_g=None, fat_g=None) + payload = nutrition_payload(entry)["nutritionLog"] + assert "mealType" not in payload + assert "nutrients" not in payload + assert "totalCarbohydrate" not in payload + assert "totalFat" not in payload + + +def test_nutrition_payload_naive_logged_at_uses_app_timezone() -> None: + entry = _entry(logged_at="2026-01-05T08:00:00", logged_date="2026-01-05") + payload = nutrition_payload(entry) + assert payload["nutritionLog"]["interval"]["startTime"].endswith("Z") + + +def test_nutrition_payload_falls_back_to_noon_when_dates_differ( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A backfilled/date-edited meal anchors to noon on the target date.""" + monkeypatch.setenv("AGENT_TIMEZONE", "UTC") + entry = _entry(logged_at="2026-01-05T23:50:00+00:00", logged_date="2026-01-08") + payload = nutrition_payload(entry)["nutritionLog"] + assert payload["interval"]["startTime"] == "2026-01-08T12:00:00Z" + assert payload["interval"]["endTime"] == "2026-01-08T12:00:01Z" + + +# --- MealService.mutate: argument validation ------------------------------ + + +async def test_mutate_requires_entry_or_entry_id(container: AppContainer) -> None: + service = MealService(container) + with pytest.raises(ValueError, match="A new meal is required"): + await service.mutate(USER_ID) + + +async def test_mutate_rejects_both_entry_and_entry_id(container: AppContainer) -> None: + service = MealService(container) + with pytest.raises(ValueError, match="cannot both select"): + await service.mutate(USER_ID, entry=_entry(), entry_id=1) + + +async def test_mutate_rejects_meal_not_found(container: AppContainer) -> None: + service = MealService(container) + with pytest.raises(ValueError, match="Meal not found"): + await service.mutate(USER_ID, entry_id=999, updates={"calories": 100}) + + +async def test_mutate_rejects_owner_mismatch(container: AppContainer) -> None: + service = MealService(container) + with pytest.raises(ValueError, match="owner does not match"): + await service.mutate(USER_ID, entry=_entry(user_id="someone-else")) + + +async def test_mutate_rejects_invalid_new_entry(container: AppContainer) -> None: + service = MealService(container) + with pytest.raises(ValueError, match="description cannot be empty"): + await service.mutate(USER_ID, entry=_entry(description="")) + + +# --- MealService.mutate: not private / not connected ----------------------- + + +async def test_mutate_create_not_private_is_local_only(container: AppContainer) -> None: + service = MealService(container) + entry_id, sync_status = await service.mutate(USER_ID, entry=_entry()) + assert sync_status == "not_enabled" + row = await container.google_health_storage.nutrition.meal(entry_id) + assert row is None + + +async def test_mutate_create_private_without_connection( + container: AppContainer, +) -> None: + service = MealService(container) + entry_id, sync_status = await service.mutate(USER_ID, private=True, entry=_entry()) + assert sync_status == "not_enabled" + row = await container.google_health_storage.nutrition.meal(entry_id) + assert row is None + + +async def test_mutate_create_private_missing_scopes(container: AppContainer) -> None: + """A newly created meal for a connected-but-unscoped account never enrolls.""" + await _connect(container, scopes=()) + service = MealService(container) + entry_id, sync_status = await service.mutate(USER_ID, private=True, entry=_entry()) + assert sync_status == "authorization_required" + row = await container.google_health_storage.nutrition.meal(entry_id) + assert row is None + + +# --- MealService.mutate: eligible create/edit/delete ------------------------ + + +async def test_mutate_create_private_eligible_enqueues_upsert( + container: AppContainer, +) -> None: + await _connect(container) + worker = MagicMock() + container.nutrition_export_worker = worker + service = MealService(container) + + entry_id, sync_status = await service.mutate(USER_ID, private=True, entry=_entry()) + + assert sync_status == "pending" + worker.wake.assert_called_once() + row = await container.google_health_storage.nutrition.meal(entry_id) + assert row is not None + assert row["status"] == "pending" + assert row["desired_operation"] == "upsert" + revisions = await container.google_health_storage.nutrition.revisions(entry_id) + assert len(revisions) == 1 + assert revisions[0]["operation"] == "upsert" + + +async def test_mutate_does_not_wake_worker_when_not_pending( + container: AppContainer, +) -> None: + worker = MagicMock() + container.nutrition_export_worker = worker + service = MealService(container) + + await service.mutate(USER_ID, entry=_entry()) + + worker.wake.assert_not_called() + + +async def test_mutate_edit_replaces_synced_point_with_delete_then_upsert( + container: AppContainer, +) -> None: + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate(USER_ID, private=True, entry=_entry()) + + nutrition = container.google_health_storage.nutrition + first_revision = (await nutrition.revisions(entry_id))[0] + await nutrition.revision_state(int(first_revision["sequence"]), "synced") + + _, sync_status = await service.mutate( + USER_ID, + private=True, + entry_id=entry_id, + updates={"calories": 450}, + ) + + assert sync_status == "pending" + revisions = await nutrition.revisions(entry_id) + assert len(revisions) == 3 + assert revisions[1]["operation"] == "delete" + assert revisions[1]["resource_name"] == first_revision["resource_name"] + assert revisions[2]["operation"] == "upsert" + + +async def test_mutate_edit_preserves_original_interval( + container: AppContainer, +) -> None: + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate( + USER_ID, + private=True, + entry=_entry(logged_at="2026-01-05T08:00:00+00:00", logged_date="2026-01-05"), + ) + nutrition = container.google_health_storage.nutrition + original_interval = (await nutrition.revisions(entry_id))[0] + + await service.mutate( + USER_ID, private=True, entry_id=entry_id, updates={"calories": 450} + ) + + revisions = await nutrition.revisions(entry_id) + original_payload = json.loads(original_interval["payload_json"]) + new_payload = json.loads(revisions[-1]["payload_json"]) + assert ( + new_payload["nutritionLog"]["interval"] + == original_payload["nutritionLog"]["interval"] + ) + + +async def test_mutate_edit_with_new_date_does_not_preserve_interval( + container: AppContainer, +) -> None: + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate( + USER_ID, + private=True, + entry=_entry(logged_at="2026-01-05T08:00:00+00:00", logged_date="2026-01-05"), + ) + + await service.mutate( + USER_ID, + private=True, + entry_id=entry_id, + updates={"logged_date": "2026-01-06"}, + ) + + nutrition = container.google_health_storage.nutrition + revisions = await nutrition.revisions(entry_id) + new_payload = json.loads(revisions[-1]["payload_json"]) + assert new_payload["nutritionLog"]["interval"]["startTime"].startswith("2026-01-06") + + +async def test_mutate_edit_of_unenrolled_meal_stays_not_enabled( + container: AppContainer, +) -> None: + """A meal logged before Google Health was connected must not backfill.""" + service = MealService(container) + entry_id, sync_status = await service.mutate(USER_ID, entry=_entry()) + assert sync_status == "not_enabled" + + await _connect(container) + _, sync_status = await service.mutate( + USER_ID, private=True, entry_id=entry_id, updates={"calories": 450} + ) + assert sync_status == "not_enabled" + + +async def test_mutate_edit_revokes_when_scope_lost_mid_flight( + container: AppContainer, +) -> None: + await _connect(container) + service = MealService(container) + entry_id, sync_status = await service.mutate(USER_ID, private=True, entry=_entry()) + assert sync_status == "pending" + + await _connect(container, scopes=()) + + _, sync_status = await service.mutate( + USER_ID, private=True, entry_id=entry_id, updates={"calories": 450} + ) + assert sync_status == "authorization_required" + row = await container.google_health_storage.nutrition.meal(entry_id) + assert row is not None + assert row["status"] == "authorization_required" + + +async def test_mutate_delete_enqueues_target_from_synced_revision( + container: AppContainer, +) -> None: + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate(USER_ID, private=True, entry=_entry()) + nutrition = container.google_health_storage.nutrition + revision = (await nutrition.revisions(entry_id))[0] + await nutrition.revision_state(int(revision["sequence"]), "synced") + + _, sync_status = await service.mutate(USER_ID, private=True, entry_id=entry_id) + + assert sync_status == "pending" + row = await nutrition.meal(entry_id) + assert row is not None + assert row["desired_operation"] == "delete" + revisions = await nutrition.revisions(entry_id) + assert revisions[-1]["operation"] == "delete" + assert revisions[-1]["resource_name"] == revision["resource_name"] + remaining = await container.calorie_storage.get_daily_summary(USER_ID, "2026-01-05") + assert remaining.entry_count == 0 + + +async def test_mutate_delete_never_dispatched_has_no_target( + container: AppContainer, +) -> None: + """Deleting a meal whose create never reached Google enqueues no revision.""" + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate(USER_ID, private=True, entry=_entry()) + + _, sync_status = await service.mutate(USER_ID, private=True, entry_id=entry_id) + + assert sync_status == "pending" + nutrition = container.google_health_storage.nutrition + row = await nutrition.meal(entry_id) + assert row is not None + assert row["desired_operation"] == "delete" + assert row["desired_revision"] is None + revisions = await nutrition.revisions(entry_id) + assert len(revisions) == 1 + assert revisions[-1]["operation"] == "upsert" + assert revisions[-1]["state"] == "queued" + + +async def test_mutate_delete_of_cancelled_export_stays_not_enabled( + container: AppContainer, +) -> None: + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate(USER_ID, private=True, entry=_entry()) + + await container.google_health_storage.nutrition.cancel(USER_ID) + + _, sync_status = await service.mutate(USER_ID, private=True, entry_id=entry_id) + assert sync_status == "not_enabled" + + +# --- MealService.mutate: transaction integrity ------------------------------ + + +async def test_mutate_rolls_back_local_write_on_export_failure( + container: AppContainer, +) -> None: + await _connect(container) + service = MealService(container) + + nutrition = container.google_health_storage.nutrition + original_enqueue = nutrition.enqueue + + async def _boom(**kwargs: object) -> None: + raise RuntimeError("simulated export failure") + + nutrition.enqueue = _boom # type: ignore[method-assign] + try: + with pytest.raises(RuntimeError, match="simulated export failure"): + await service.mutate(USER_ID, private=True, entry=_entry()) + finally: + nutrition.enqueue = original_enqueue # type: ignore[method-assign] + + summary = await container.calorie_storage.get_daily_summary(USER_ID, "2026-01-05") + assert summary.entry_count == 0 + + +async def test_mutate_account_mismatch_blocks_enqueue( + container: AppContainer, +) -> None: + """A stored export row bound to a different account must not be reused.""" + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate(USER_ID, private=True, entry=_entry()) + + await container.conn.execute( + "UPDATE nutrition_exports SET health_user_id = ? WHERE meal_id = ?", + (OTHER_HEALTH_USER_ID, entry_id), + ) + + _, sync_status = await service.mutate( + USER_ID, private=True, entry_id=entry_id, updates={"calories": 450} + ) + assert sync_status == "not_enabled" + + +async def test_mutate_falls_back_to_standalone_nutrition_storage( + container: AppContainer, +) -> None: + """A health storage without a ``.nutrition`` attribute still works.""" + from blacki.health.nutrition_storage import NutritionStorage + + class _NoNutritionHealthStorage: + async def initialize(self) -> None: + return None + + async def close(self) -> None: + return None + + container._google_health_storage = _NoNutritionHealthStorage() # type: ignore[assignment] + service = MealService(container) + + entry_id, sync_status = await service.mutate(USER_ID, entry=_entry()) + + assert sync_status == "not_enabled" + assert isinstance(service._nutrition, NutritionStorage) + assert entry_id > 0 + + +async def test_mutate_delete_pauses_when_stored_health_user_id_is_blank( + container: AppContainer, +) -> None: + """A stale export row missing its account id must pause, never crash.""" + service = MealService(container) + entry_id, _ = await service.mutate(USER_ID, entry=_entry()) + + nutrition = container.google_health_storage.nutrition + await nutrition.enqueue( + meal_id=entry_id, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id="", + payload=None, + operation="delete", + ) + + _, sync_status = await service.mutate(USER_ID, private=True, entry_id=entry_id) + assert sync_status == "authorization_required" + + +async def test_mutate_edit_skips_carry_forward_when_prior_payload_missing( + container: AppContainer, +) -> None: + """A corrupted/absent prior payload must not stop the new edit from syncing.""" + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate( + USER_ID, + private=True, + entry=_entry(logged_at="2026-01-05T08:00:00+00:00", logged_date="2026-01-05"), + ) + await container.conn.execute( + "UPDATE nutrition_revisions SET payload_json = NULL WHERE meal_id = ?", + (entry_id,), + ) + + _, sync_status = await service.mutate( + USER_ID, private=True, entry_id=entry_id, updates={"calories": 450} + ) + + assert sync_status == "pending" + nutrition = container.google_health_storage.nutrition + revisions = await nutrition.revisions(entry_id) + new_payload = json.loads(revisions[-1]["payload_json"]) + assert new_payload["nutritionLog"]["interval"]["startTime"] == ( + "2026-01-05T08:00:00Z" + ) + + +async def test_mutate_edit_skips_carry_forward_when_prior_payload_has_no_log( + container: AppContainer, +) -> None: + """A prior payload missing its nutritionLog key must not carry an interval.""" + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate(USER_ID, private=True, entry=_entry()) + await container.conn.execute( + "UPDATE nutrition_revisions SET payload_json = ? WHERE meal_id = ?", + (json.dumps({"other": True}), entry_id), + ) + + _, sync_status = await service.mutate( + USER_ID, private=True, entry_id=entry_id, updates={"calories": 450} + ) + + assert sync_status == "pending" + + +async def test_latest_remote_resource_skips_past_a_pending_delete( + container: AppContainer, +) -> None: + """A trailing pending delete must not hide an earlier synced create.""" + await _connect(container) + service = MealService(container) + entry_id, _ = await service.mutate(USER_ID, private=True, entry=_entry()) + + nutrition = container.google_health_storage.nutrition + upsert_revision = (await nutrition.revisions(entry_id))[0] + await nutrition.revision_state(int(upsert_revision["sequence"]), "synced") + await nutrition.enqueue( + meal_id=entry_id, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=USER_ID, + payload=None, + operation="delete", + target_resource_name=str(upsert_revision["resource_name"]), + ) + + target = await service._latest_remote_resource(nutrition, entry_id) + + assert target == upsert_revision["resource_name"] + + +async def test_get_meal_service_binds_the_process_container( + container: AppContainer, +) -> None: + set_container(container) + try: + service = get_meal_service() + assert isinstance(service, MealService) + assert service.container is container + finally: + reset_container_for_tests() diff --git a/tests/calories/test_tools.py b/tests/calories/test_tools.py index 9d19f55..917576f 100644 --- a/tests/calories/test_tools.py +++ b/tests/calories/test_tools.py @@ -1,4 +1,6 @@ # mypy: disable-error-code="no-untyped-def" +from types import SimpleNamespace +from typing import cast from unittest.mock import AsyncMock, create_autospec, patch import pytest @@ -7,6 +9,8 @@ from blacki.calories.storage import DailySummary from blacki.calories.tools import ( + _is_private_tool_context, + _meal_saved_message, delete_meal, edit_meal, get_calorie_summary, @@ -333,3 +337,216 @@ async def test_log_meal_pydantic_validation_error( assert result["status"] == "error" assert "validation failed" in result["message"].lower() + + +# Tests for MealService dispatch (Google Health export enrollment) + + +@pytest.mark.asyncio +@patch("blacki.calories.tools.get_storage") +@patch("blacki.calories.tools.get_preferences_storage") +@patch("blacki.calories.tools._try_get_meal_service") +async def test_log_meal_uses_meal_service_when_available( + mock_try_get_service, mock_get_pref, mock_get_storage, mock_tool_context +) -> None: + mock_service = AsyncMock() + mock_service.mutate.return_value = (7, "pending") + mock_try_get_service.return_value = mock_service + + mock_storage = AsyncMock() + mock_get_storage.return_value = mock_storage + mock_storage.get_daily_summary.return_value = DailySummary( + date="2026-04-26", total_calories=500, entry_count=1 + ) + mock_pref = AsyncMock() + mock_get_pref.return_value = mock_pref + mock_pref.get.return_value = 2000 + mock_tool_context.state = {"telegram_chat_type": "private"} + + result = await log_meal( + mock_tool_context, description="apple", estimated_calories=95 + ) + + assert result["status"] == "success" + assert result["entry_id"] == 7 + assert result["google_health_sync"] == "pending" + assert "sync is pending" in result["message"] + mock_service.mutate.assert_called_once() + assert mock_service.mutate.call_args.kwargs["private"] is True + + +@pytest.mark.asyncio +@patch("blacki.calories.tools.get_storage") +@patch("blacki.calories.tools.get_preferences_storage") +async def test_log_meal_summary_read_failure_still_succeeds( + mock_get_pref, mock_get_storage, mock_tool_context +) -> None: + mock_storage = AsyncMock() + mock_get_storage.return_value = mock_storage + mock_storage.add_entry.return_value = 1 + mock_storage.get_daily_summary.side_effect = RuntimeError("db unavailable") + mock_pref = AsyncMock() + mock_get_pref.return_value = mock_pref + mock_pref.get.return_value = 2000 + + result = await log_meal( + mock_tool_context, description="apple", estimated_calories=95 + ) + + assert result["status"] == "success" + assert "daily summary is unavailable" in result["message"] + assert "daily_total" not in result + assert "remaining" not in result + + +@pytest.mark.asyncio +@patch("blacki.calories.tools.get_storage") +@patch("blacki.calories.tools.get_preferences_storage") +async def test_log_meal_goal_read_failure_still_succeeds( + mock_get_pref, mock_get_storage, mock_tool_context +) -> None: + mock_storage = AsyncMock() + mock_get_storage.return_value = mock_storage + mock_storage.add_entry.return_value = 1 + mock_storage.get_daily_summary.return_value = DailySummary( + date="2026-04-26", total_calories=500, entry_count=1 + ) + mock_get_pref.side_effect = RuntimeError("prefs unavailable") + + result = await log_meal( + mock_tool_context, description="apple", estimated_calories=95 + ) + + assert result["status"] == "success" + assert "calorie_goal" not in result + assert result["daily_total"] == 500 + assert "remaining" not in result + + +@pytest.mark.asyncio +async def test_edit_meal_rejects_empty_description(mock_tool_context) -> None: + result = await edit_meal(mock_tool_context, entry_id=1, description=" ") + assert result["status"] == "error" + assert "description cannot be empty" in result["message"] + + +@pytest.mark.asyncio +async def test_edit_meal_rejects_nonpositive_calories(mock_tool_context) -> None: + result = await edit_meal(mock_tool_context, entry_id=1, estimated_calories=0) + assert result["status"] == "error" + assert "estimated_calories must be > 0" in result["message"] + + +@pytest.mark.asyncio +async def test_edit_meal_rejects_invalid_macros(mock_tool_context) -> None: + result = await edit_meal(mock_tool_context, entry_id=1, protein_g=-5.0) + assert result["status"] == "error" + assert "macros must be finite and nonnegative" in result["message"] + + +@pytest.mark.asyncio +@patch("blacki.calories.tools.get_storage") +async def test_edit_meal_value_error_from_storage( + mock_get_storage, mock_tool_context +) -> None: + mock_storage = AsyncMock() + mock_get_storage.return_value = mock_storage + mock_storage.update_entry.side_effect = ValueError("owner mismatch") + + result = await edit_meal(mock_tool_context, entry_id=1, estimated_calories=200) + + assert result["status"] == "error" + assert result["message"] == "owner mismatch" + + +@pytest.mark.asyncio +@patch("blacki.calories.tools._try_get_meal_service") +async def test_edit_meal_uses_meal_service_when_available( + mock_try_get_service, mock_tool_context +) -> None: + mock_service = AsyncMock() + mock_service.mutate.return_value = (1, "pending") + mock_try_get_service.return_value = mock_service + + result = await edit_meal(mock_tool_context, entry_id=1, estimated_calories=200) + + assert result["status"] == "success" + assert result["google_health_sync"] == "pending" + mock_service.mutate.assert_called_once_with( + "user1", + private=False, + entry_id=1, + updates={"calories": 200}, + ) + + +@pytest.mark.asyncio +@patch("blacki.calories.tools.get_storage") +async def test_delete_meal_value_error_from_storage( + mock_get_storage, mock_tool_context +) -> None: + mock_storage = AsyncMock() + mock_get_storage.return_value = mock_storage + mock_storage.delete_entry.side_effect = ValueError("owner mismatch") + + result = await delete_meal(mock_tool_context, entry_id=1) + + assert result["status"] == "error" + assert result["message"] == "owner mismatch" + + +@pytest.mark.asyncio +@patch("blacki.calories.tools._try_get_meal_service") +async def test_delete_meal_uses_meal_service_when_available( + mock_try_get_service, mock_tool_context +) -> None: + mock_service = AsyncMock() + mock_service.mutate.return_value = (1, "not_enabled") + mock_try_get_service.return_value = mock_service + + result = await delete_meal(mock_tool_context, entry_id=1) + + assert result["status"] == "success" + assert result["google_health_sync"] == "not_enabled" + mock_service.mutate.assert_called_once_with("user1", private=False, entry_id=1) + + +def test_is_private_tool_context_true_for_private_chat() -> None: + ctx = SimpleNamespace(state={"telegram_chat_type": "private"}) + assert _is_private_tool_context(cast(ToolContext, ctx)) is True + + +def test_is_private_tool_context_false_for_group_chat() -> None: + ctx = SimpleNamespace(state={"telegram_chat_type": "group"}) + assert _is_private_tool_context(cast(ToolContext, ctx)) is False + + +def test_is_private_tool_context_false_without_state() -> None: + ctx = SimpleNamespace() + assert _is_private_tool_context(cast(ToolContext, ctx)) is False + + +def test_is_private_tool_context_false_when_state_has_no_getter() -> None: + ctx = SimpleNamespace(state=object()) + assert _is_private_tool_context(cast(ToolContext, ctx)) is False + + +def test_meal_saved_message_pending() -> None: + message = _meal_saved_message("Logged", "pending") + assert message == "Logged Saved in Blacki; Google Health sync is pending." + + +def test_meal_saved_message_authorization_required() -> None: + message = _meal_saved_message("Logged", "authorization_required") + assert message == "Logged Saved in Blacki; reconnect Google Health to sync it." + + +def test_meal_saved_message_failed() -> None: + message = _meal_saved_message("Logged", "failed") + assert message == ( + "Logged Saved in Blacki; Google Health sync failed and will retry." + ) + + +def test_meal_saved_message_not_enabled() -> None: + assert _meal_saved_message("Logged", "not_enabled") == "Logged Saved in Blacki." diff --git a/tests/test_google_health.py b/tests/test_google_health.py index 71c2617..015f625 100644 --- a/tests/test_google_health.py +++ b/tests/test_google_health.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +import json from collections.abc import AsyncGenerator, Mapping, Sequence from datetime import UTC, datetime, timedelta +from email.utils import format_datetime from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from urllib.parse import parse_qs, urlsplit @@ -21,11 +23,15 @@ GoogleHealthAuthError, GoogleHealthClient, GoogleHealthIdentity, + GoogleHealthOperation, GoogleTokenResponse, _filter_for_data_type, _json_object, + _nutrition_parent, + _parse_operation, _parse_token_response, _raise_provider_error, + _retry_after_seconds, ) from blacki.health.config import ( GOOGLE_HEALTH_SCOPES, @@ -108,6 +114,8 @@ def test_health_config_and_cipher() -> None: "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", "https://www.googleapis.com/auth/googlehealth.sleep.readonly", + "https://www.googleapis.com/auth/googlehealth.nutrition.readonly", + "https://www.googleapis.com/auth/googlehealth.nutrition.writeonly", ) query = parse_qs(urlsplit(config.authorization_url("state-value")).query) @@ -322,6 +330,25 @@ def handler(request: httpx.Request) -> httpx.Response: _parse_token_response({}, require_refresh_token=False) +@pytest.mark.asyncio +async def test_google_health_token_request_wraps_transport_errors() -> None: + """A network failure reaching Google's token endpoint is a safe, typed error.""" + config = _config() + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + client = GoogleHealthClient( + config, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + with pytest.raises(GoogleHealthApiError) as error: + await client.refresh_access_token("refresh") + assert error.value.error_code == "transport_error" + assert error.value.transport is True + await client.close() + + def test_structured_google_health_error_exposes_only_reason_code() -> None: """Extract Google RPC ErrorInfo reasons without exposing provider text.""" with pytest.raises(GoogleHealthApiError) as error: @@ -400,6 +427,211 @@ async def test_google_health_client_handles_non_list_pages_and_lazy_client() -> await lazy_client.close() +_NUTRITION_RESOURCE_NAME = ( + "users/health-user-1/dataTypes/nutrition-log/dataPoints/blacki-test1234" +) + + +@pytest.mark.asyncio +async def test_google_health_client_nutrition_methods_use_safe_requests() -> None: + """Create, fetch, and delete a nutrition log through the expected endpoints.""" + config = _config() + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.method == "POST" and request.url.path.endswith("/dataPoints"): + return _response(200, {"done": True, "response": {}}) + if request.method == "GET": + return _response(200, {"name": _NUTRITION_RESOURCE_NAME}) + if request.method == "POST" and request.url.path.endswith( + "/dataPoints:batchDelete" + ): + return _response(200, {"done": True, "response": {}}) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + client = GoogleHealthClient( + config, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + created = await client.create_nutrition_log( + "access", _NUTRITION_RESOURCE_NAME, {"nutritionLog": {"calories": 500}} + ) + fetched = await client.get_data_point("access", _NUTRITION_RESOURCE_NAME) + deleted = await client.delete_nutrition_log("access", _NUTRITION_RESOURCE_NAME) + await client.close() + + assert created == GoogleHealthOperation(done=True, response={}) + assert fetched == {"name": _NUTRITION_RESOURCE_NAME} + assert deleted == GoogleHealthOperation(done=True, response={}) + assert len(requests) == 3 + assert requests[0].url.path == ( + "/v4/users/health-user-1/dataTypes/nutrition-log/dataPoints" + ) + assert requests[1].url.path == f"/v4/{_NUTRITION_RESOURCE_NAME}" + assert requests[2].url.path == ( + "/v4/users/health-user-1/dataTypes/nutrition-log/dataPoints:batchDelete" + ) + create_body = json.loads(requests[0].content) + assert create_body["name"] == _NUTRITION_RESOURCE_NAME + assert create_body["nutritionLog"] == {"calories": 500} + delete_body = json.loads(requests[2].content) + assert delete_body == {"names": [_NUTRITION_RESOURCE_NAME]} + + +@pytest.mark.asyncio +async def test_google_health_client_api_transport_error_is_safe() -> None: + """A network failure against the Health API never leaks transport details.""" + config = _config() + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("secret transport failure", request=request) + + client = GoogleHealthClient( + config, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + with pytest.raises(GoogleHealthApiError) as error: + await client.get_identity("access") + assert error.value.transport is True + assert error.value.error_code == "transport_error" + assert "secret transport failure" not in str(error.value) + await client.close() + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ( + {"done": True, "error": {"status": "NOT_FOUND"}}, + GoogleHealthOperation(done=True, error_code="NOT_FOUND"), + ), + ( + {"done": True, "error": {"status": "x" * 90}}, + GoogleHealthOperation(done=True, error_code="x" * 80), + ), + ( + {"done": True, "error": {"code": 404}}, + GoogleHealthOperation(done=True, error_code="provider_error_404"), + ), + ( + {"done": True, "error": {}}, + GoogleHealthOperation(done=True, error_code="provider_error"), + ), + ( + {"done": True, "error": "quota exceeded"}, + GoogleHealthOperation(done=True, error_code="quota exceeded"), + ), + ( + {"done": True, "error": "x" * 90}, + GoogleHealthOperation(done=True, error_code="x" * 80), + ), + ( + {"done": True, "error": "café"}, + GoogleHealthOperation(done=True, error_code="provider_error"), + ), + ( + {"done": True, "error": "\x01bad"}, + GoogleHealthOperation(done=True, error_code="provider_error"), + ), + ( + {"done": True, "error": 123}, + GoogleHealthOperation(done=True, error_code="provider_error"), + ), + ( + {"done": True, "response": {"ok": True}, "name": "operations/1"}, + GoogleHealthOperation( + done=True, name="operations/1", response={"ok": True} + ), + ), + ( + {"done": False, "name": ""}, + GoogleHealthOperation(done=False, name=None), + ), + ], +) +def test_parse_operation_handles_all_error_shapes( + payload: dict[str, object], expected: GoogleHealthOperation +) -> None: + """Every provider-supplied error shape maps to a bounded, safe error code.""" + assert _parse_operation(payload) == expected + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"done": "true"}, + {"done": None}, + {"done": True}, + {"done": True, "error": None, "response": None}, + ], +) +def test_parse_operation_rejects_incomplete_payloads( + payload: dict[str, object], +) -> None: + """Missing ``done`` or a completed operation without error/response fails closed.""" + with pytest.raises(GoogleHealthApiError, match="incomplete"): + _parse_operation(payload) + + +def test_nutrition_parent_validates_resource_name() -> None: + """Only a well-formed nutrition data point name yields its parent path.""" + assert _nutrition_parent(_NUTRITION_RESOURCE_NAME) == ( + "users/health-user-1/dataTypes/nutrition-log" + ) + with pytest.raises(ValueError, match="not a valid nutrition data point name"): + _nutrition_parent("users/health-user-1/dataTypes/nutrition-log/dataPoints/abc") + with pytest.raises(ValueError, match="not a valid nutrition data point name"): + _nutrition_parent("users/health-user-1/dataTypes/steps/dataPoints/blacki-1234") + + +def test_retry_after_seconds_parses_bounded_values() -> None: + """Numeric, HTTP-date, missing, garbage, and non-finite values are all safe.""" + assert _retry_after_seconds(_response(200, {})) is None + + numeric = httpx.Response( + 200, + headers={"Retry-After": "120"}, + request=httpx.Request("GET", "https://example.test"), + ) + assert _retry_after_seconds(numeric) == 120.0 + + future = datetime.now(UTC) + timedelta(seconds=90) + date_response = httpx.Response( + 200, + headers={"Retry-After": format_datetime(future, usegmt=True)}, + request=httpx.Request("GET", "https://example.test"), + ) + parsed = _retry_after_seconds(date_response) + assert parsed is not None + assert 80.0 <= parsed <= 100.0 + + garbage = httpx.Response( + 200, + headers={"Retry-After": "not-a-number-or-date"}, + request=httpx.Request("GET", "https://example.test"), + ) + assert _retry_after_seconds(garbage) is None + + for non_finite in ("nan", "inf", "-inf"): + response = httpx.Response( + 200, + headers={"Retry-After": non_finite}, + request=httpx.Request("GET", "https://example.test"), + ) + assert _retry_after_seconds(response) is None + + naive_date_response = httpx.Response( + 200, + headers={"Retry-After": "Mon, 01 Jan 2077 00:00:00"}, + request=httpx.Request("GET", "https://example.test"), + ) + naive_parsed = _retry_after_seconds(naive_date_response) + assert naive_parsed is not None + assert naive_parsed > 0.0 + + @pytest.mark.parametrize( ("data_type", "fragment"), [ @@ -923,6 +1155,34 @@ async def test_health_storage_lifecycle( assert _parse_timestamp("2026-08-16T00:00:00").tzinfo == UTC +@pytest.mark.asyncio +async def test_delete_connection_rolls_back_on_failure( + health_storage: SqliteGoogleHealthStorage, +) -> None: + """A mid-transaction failure must not leave a partially deleted connection.""" + await health_storage.upsert_connection( + telegram_user_id="telegram-chat-99", + encrypted_refresh_token=_config().cipher.encrypt("refresh"), + health_user_id="health-id-99", + legacy_fitbit_user_id=None, + scopes=GOOGLE_HEALTH_SCOPES, + ) + original_cancel = health_storage.nutrition.cancel + + async def _boom(user_id: str) -> None: + raise RuntimeError("simulated failure") + + health_storage.nutrition.cancel = _boom # type: ignore[method-assign] + try: + with pytest.raises(RuntimeError, match="simulated failure"): + await health_storage.delete_connection("telegram-chat-99") + finally: + health_storage.nutrition.cancel = original_cancel # type: ignore[method-assign] + + connection = await health_storage.get_connection("telegram-chat-99") + assert connection is not None + + @pytest.mark.asyncio async def test_app_container_initializes_and_closes_google_health_storage() -> None: """The shared container owns the health schema and closes it with the app.""" @@ -1395,10 +1655,19 @@ async def list_points(*args: object, **kwargs: object) -> list[dict[str, object] await health_storage.upsert_daily_summaries( "telegram-chat-42", [{"date": stale_date, "steps": 1}] ) + await health_storage.nutrition.enqueue( + meal_id=1, + owner_id="telegram-chat-42", + telegram_user_id="telegram-chat-42", + health_user_id="health-id", + payload={"nutritionLog": {}}, + operation="upsert", + ) result = await service.sync_user("telegram-chat-42", days=7) assert result.status == "success" assert result.records_fetched == 1 assert "exercise" in result.unavailable_data_types + assert result.google_health_sync == {"pending": 1} summary = await service.summary("telegram-chat-42", days=7) assert summary["status"] == "success" assert summary["days"][0]["steps"] == 8420 diff --git a/tests/test_nutrition_storage.py b/tests/test_nutrition_storage.py new file mode 100644 index 0000000..e4513e5 --- /dev/null +++ b/tests/test_nutrition_storage.py @@ -0,0 +1,362 @@ +"""Tests for NutritionStorage: the durable meal export queue.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from typing import Any + +import aiosqlite +import pytest + +from blacki.health.storage import SqliteGoogleHealthStorage + +USER_ID = "telegram-chat-1" +OWNER_ID = "telegram-chat-1" +HEALTH_USER_ID = "health-user-1" +PAYLOAD: dict[str, Any] = { + "nutritionLog": { + "interval": { + "startTime": "2026-01-01T12:00:00Z", + "endTime": "2026-01-01T12:00:01Z", + "startUtcOffset": "0s", + "endUtcOffset": "0s", + }, + "foodDisplayName": "Oatmeal", + "energy": {"kcal": 300}, + } +} + + +@pytest.fixture +async def storage() -> AsyncGenerator[SqliteGoogleHealthStorage, None]: + conn = await aiosqlite.connect(":memory:", isolation_level=None) + conn.row_factory = aiosqlite.Row + store = SqliteGoogleHealthStorage(conn, asyncio.Lock()) + await store.initialize() + yield store + await store.close() + await conn.close() + + +# --- enqueue() validation guards ------------------------------------------- + + +async def test_enqueue_rejects_unsupported_operation( + storage: SqliteGoogleHealthStorage, +) -> None: + with pytest.raises(ValueError, match="unsupported nutrition export operation"): + await storage.nutrition.enqueue( + meal_id=1, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="sync", + ) + + +async def test_enqueue_rejects_owner_change( + storage: SqliteGoogleHealthStorage, +) -> None: + await storage.nutrition.enqueue( + meal_id=2, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + with pytest.raises(ValueError, match="owner cannot change"): + await storage.nutrition.enqueue( + meal_id=2, + owner_id="a-different-owner", + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + + +async def test_enqueue_rejects_telegram_user_change( + storage: SqliteGoogleHealthStorage, +) -> None: + await storage.nutrition.enqueue( + meal_id=3, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + with pytest.raises(ValueError, match="identity cannot change"): + await storage.nutrition.enqueue( + meal_id=3, + owner_id=OWNER_ID, + telegram_user_id="a-different-chat", + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + + +async def test_enqueue_rejects_health_account_change( + storage: SqliteGoogleHealthStorage, +) -> None: + await storage.nutrition.enqueue( + meal_id=4, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + with pytest.raises(ValueError, match="account cannot change"): + await storage.nutrition.enqueue( + meal_id=4, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id="a-different-health-account", + payload=PAYLOAD, + operation="upsert", + ) + + +async def test_enqueue_upsert_requires_payload( + storage: SqliteGoogleHealthStorage, +) -> None: + with pytest.raises(ValueError, match="upsert requires a payload"): + await storage.nutrition.enqueue( + meal_id=5, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="upsert", + ) + + +# --- latest_payload() -------------------------------------------------- + + +async def test_latest_payload_returns_none_without_revisions( + storage: SqliteGoogleHealthStorage, +) -> None: + assert await storage.nutrition.latest_payload(999) is None + + +async def test_latest_payload_skips_null_and_returns_older( + storage: SqliteGoogleHealthStorage, +) -> None: + await storage.nutrition.enqueue( + meal_id=20, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + # A subsequent delete records a revision with payload_json=None, but the + # older create revision's payload should still be found and returned. + await storage.nutrition.enqueue( + meal_id=20, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name="users/health-user-1/dataTypes/nutrition-log/dataPoints/x", + ) + + assert await storage.nutrition.latest_payload(20) == PAYLOAD + + +async def test_latest_payload_skips_malformed_json( + storage: SqliteGoogleHealthStorage, +) -> None: + await storage.nutrition.enqueue( + meal_id=21, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + # Simulate a corrupted newer revision row without going through enqueue. + await storage.nutrition._conn.execute( + """ + INSERT INTO nutrition_revisions + (meal_id, resource_name, operation, payload_json, state) + VALUES (?, ?, ?, ?, 'queued') + """, + (21, "resource-x", "upsert", "{not valid json"), + ) + + assert await storage.nutrition.latest_payload(21) == PAYLOAD + + +async def test_latest_payload_returns_none_when_nothing_decodes_to_dict( + storage: SqliteGoogleHealthStorage, +) -> None: + await storage.nutrition._conn.execute( + """ + INSERT INTO nutrition_revisions + (meal_id, resource_name, operation, payload_json, state) + VALUES (?, ?, ?, ?, 'queued') + """, + (22, "resource-a", "delete", None), + ) + await storage.nutrition._conn.execute( + """ + INSERT INTO nutrition_revisions + (meal_id, resource_name, operation, payload_json, state) + VALUES (?, ?, ?, ?, 'queued') + """, + (22, "resource-b", "upsert", "[1, 2, 3]"), + ) + await storage.nutrition._conn.execute( + """ + INSERT INTO nutrition_revisions + (meal_id, resource_name, operation, payload_json, state) + VALUES (?, ?, ?, ?, 'queued') + """, + (22, "resource-c", "upsert", "not json at all"), + ) + + assert await storage.nutrition.latest_payload(22) is None + + +# --- result() ------------------------------------------------------------ + + +async def test_result_without_expected_revision_ignores_guard( + storage: SqliteGoogleHealthStorage, +) -> None: + """The pre-feature calling convention: no guard is applied at all.""" + await storage.nutrition.enqueue( + meal_id=30, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + + updated = await storage.nutrition.result(30, "synced") + + assert updated is True + row = await storage.nutrition.meal(30) + assert row is not None + assert row["status"] == "synced" + + +async def test_result_expected_revision_none_matches_delete( + storage: SqliteGoogleHealthStorage, +) -> None: + await storage.nutrition.enqueue( + meal_id=31, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=None, + ) + row = await storage.nutrition.meal(31) + assert row is not None + assert row["desired_revision"] is None + + updated = await storage.nutrition.result(31, "synced", expected_revision=None) + + assert updated is True + row = await storage.nutrition.meal(31) + assert row is not None + assert row["status"] == "synced" + + +async def test_result_blocks_stale_write_when_revision_changed( + storage: SqliteGoogleHealthStorage, +) -> None: + await storage.nutrition.enqueue( + meal_id=32, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + original_row = await storage.nutrition.meal(32) + assert original_row is not None + original_revision = original_row["desired_revision"] + + # A newer edit lands before the worker records its result. + await storage.nutrition.enqueue( + meal_id=32, + owner_id=OWNER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + newer_row = await storage.nutrition.meal(32) + assert newer_row is not None + assert newer_row["desired_revision"] != original_revision + + updated = await storage.nutrition.result( + 32, "synced", expected_revision=original_revision + ) + + assert updated is False + row = await storage.nutrition.meal(32) + assert row is not None + assert row["status"] == "pending" + + +# --- counts() -------------------------------------------------------------- + + +async def test_counts_returns_empty_dict_for_unknown_user( + storage: SqliteGoogleHealthStorage, +) -> None: + assert await storage.nutrition.counts("no-such-user") == {} + + +async def test_counts_excludes_cancelled_rows( + storage: SqliteGoogleHealthStorage, +) -> None: + counts_user = "counts-user" + await storage.nutrition.enqueue( + meal_id=40, + owner_id=counts_user, + telegram_user_id=counts_user, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.enqueue( + meal_id=41, + owner_id=counts_user, + telegram_user_id=counts_user, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.enqueue( + meal_id=42, + owner_id=counts_user, + telegram_user_id=counts_user, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition._conn.execute( + "UPDATE nutrition_exports SET status = 'synced' WHERE meal_id = ?", (41,) + ) + await storage.nutrition._conn.execute( + "UPDATE nutrition_exports SET status = 'cancelled' WHERE meal_id = ?", (42,) + ) + + counts = await storage.nutrition.counts(counts_user) + + assert counts == {"pending": 1, "synced": 1} + assert "cancelled" not in counts diff --git a/tests/test_nutrition_worker.py b/tests/test_nutrition_worker.py new file mode 100644 index 0000000..4c64fbd --- /dev/null +++ b/tests/test_nutrition_worker.py @@ -0,0 +1,1289 @@ +"""Tests for the Google Health nutrition export background worker.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import aiosqlite +import pytest +from cryptography.fernet import Fernet + +from blacki.health.client import ( + GoogleHealthApiError, + GoogleHealthAuthError, + GoogleHealthOperation, + GoogleTokenResponse, +) +from blacki.health.config import ( + GOOGLE_HEALTH_READ_SCOPES, + GOOGLE_HEALTH_SCOPES, + GoogleHealthConfig, +) +from blacki.health.nutrition_worker import NutritionExportWorker +from blacki.health.storage import SqliteGoogleHealthStorage + +USER_ID = "telegram-chat-1" +HEALTH_USER_ID = "health-user-1" +PAYLOAD = { + "nutritionLog": { + "interval": { + "startTime": "2026-01-01T12:00:00Z", + "endTime": "2026-01-01T12:00:01Z", + "startUtcOffset": "0s", + "endUtcOffset": "0s", + }, + "foodDisplayName": "Oatmeal", + "energy": {"kcal": 300}, + } +} + + +def _config() -> GoogleHealthConfig: + return GoogleHealthConfig( + client_id="client-id", + client_secret="client-secret", + redirect_uri="https://example.test/integrations/google-health/callback", + token_encryption_key=Fernet.generate_key().decode(), + sync_interval_hours=12, + manual_refresh_cooldown_seconds=3600, + oauth_state_ttl_seconds=600, + ) + + +def _token() -> GoogleTokenResponse: + return GoogleTokenResponse( + access_token="access-token", + expires_in=3600, + refresh_token=None, + scopes=GOOGLE_HEALTH_SCOPES, + ) + + +@pytest.fixture +async def storage() -> AsyncGenerator[SqliteGoogleHealthStorage, None]: + conn = await aiosqlite.connect(":memory:", isolation_level=None) + conn.row_factory = aiosqlite.Row + store = SqliteGoogleHealthStorage(conn, asyncio.Lock()) + await store.initialize() + yield store + await store.close() + await conn.close() + + +async def _connect( + storage: SqliteGoogleHealthStorage, config: GoogleHealthConfig +) -> None: + await storage.upsert_connection( + telegram_user_id=USER_ID, + encrypted_refresh_token=config.cipher.encrypt("refresh-token"), + health_user_id=HEALTH_USER_ID, + legacy_fitbit_user_id=None, + scopes=GOOGLE_HEALTH_SCOPES, + ) + + +def _worker( + storage: SqliteGoogleHealthStorage, config: GoogleHealthConfig +) -> tuple[NutritionExportWorker, AsyncMock]: + client = AsyncMock() + worker = NutritionExportWorker(config, storage, client=client) + return worker, client + + +async def test_worker_dispatches_create_success( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=1, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.return_value = GoogleHealthOperation( + done=True, name="op/1", response={} + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(1) + assert row is not None + assert row["status"] == "synced" + revisions = await storage.nutrition.revisions(1) + assert revisions[0]["state"] == "synced" + client.create_nutrition_log.assert_awaited_once() + + +async def test_worker_retries_transient_then_verifies_success( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=2, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.side_effect = GoogleHealthApiError( + "network blip", transport=True + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(2) + assert row is not None + assert row["status"] == "pending" + assert row["next_attempt"] > 0 + revisions = await storage.nutrition.revisions(2) + assert revisions[0]["state"] == "uncertain" + + client.get_data_point.return_value = PAYLOAD + + await storage.nutrition._conn.execute( + "UPDATE nutrition_exports SET next_attempt = 0 WHERE meal_id = 2" + ) + await worker._dispatch_due() + + row = await storage.nutrition.meal(2) + assert row is not None + assert row["status"] == "synced" + client.get_data_point.assert_awaited_once() + + +async def test_worker_marks_permanent_failure( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=3, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.side_effect = GoogleHealthApiError( + "bad request", status_code=400, error_code="invalid_argument" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(3) + assert row is not None + assert row["status"] == "failed" + assert row["error_code"] == "invalid_argument" + revisions = await storage.nutrition.revisions(3) + assert revisions[0]["state"] == "failed" + + +async def test_worker_pauses_on_auth_error( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=4, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.side_effect = GoogleHealthAuthError( + "revoked", status_code=401, error_code="invalid_grant" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(4) + assert row is not None + assert row["status"] == "authorization_required" + connection = await storage.get_connection(USER_ID) + assert connection is not None + assert connection.status == "reauthorization_required" + + +async def test_worker_preserves_connection_when_nutrition_scope_missing( + storage: SqliteGoogleHealthStorage, +) -> None: + """A read-only reconnect must not disable the whole Health connection.""" + config = _config() + await storage.upsert_connection( + telegram_user_id=USER_ID, + encrypted_refresh_token=config.cipher.encrypt("refresh-token"), + health_user_id=HEALTH_USER_ID, + legacy_fitbit_user_id=None, + scopes=GOOGLE_HEALTH_READ_SCOPES, + ) + await storage.nutrition.enqueue( + meal_id=100, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(100) + assert row is not None + assert row["status"] == "authorization_required" + assert row["error_code"] == "nutrition_scope_missing" + connection = await storage.get_connection(USER_ID) + assert connection is not None + assert connection.status == "connected" + assert connection.encrypted_refresh_token is not None + client.refresh_access_token.assert_not_awaited() + client.create_nutrition_log.assert_not_awaited() + + +async def test_worker_dispatches_delete_success( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-x" + ) + await storage.nutrition.enqueue( + meal_id=5, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.delete_nutrition_log.return_value = GoogleHealthOperation( + done=True, name="op/2", response={} + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(5) + assert row is not None + assert row["status"] == "deleted" + revisions = await storage.nutrition.revisions(5) + assert revisions[0]["state"] == "deleted" + + +async def test_worker_resolves_uncertain_delete_via_404( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-y" + ) + await storage.nutrition.enqueue( + meal_id=6, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthApiError( + "not found", status_code=404 + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(6) + assert row is not None + assert row["status"] == "deleted" + client.delete_nutrition_log.assert_not_awaited() + + +async def test_worker_reconciles_persisted_in_flight_delete( + storage: SqliteGoogleHealthStorage, +) -> None: + """A crash right after "in_flight" persists must verify, not re-delete.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-z" + ) + await storage.nutrition.enqueue( + meal_id=102, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + await storage.nutrition.revision_state(1, "in_flight") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthApiError( + "not found", status_code=404 + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(102) + assert row is not None + assert row["status"] == "deleted" + revisions = await storage.nutrition.revisions(102) + assert revisions[0]["state"] == "deleted" + client.get_data_point.assert_awaited_once() + client.delete_nutrition_log.assert_not_awaited() + + +async def test_worker_cancels_stale_revision_never_dispatched( + storage: SqliteGoogleHealthStorage, +) -> None: + """A queued create superseded by a delete before it was ever sent is dropped.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=7, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + # Nothing was ever dispatched remotely, so the delete has no target. + await storage.nutrition.enqueue( + meal_id=7, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=None, + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + + await worker._dispatch_due() + + row = await storage.nutrition.meal(7) + assert row is not None + assert row["status"] == "deleted" + client.create_nutrition_log.assert_not_awaited() + revisions = await storage.nutrition.revisions(7) + assert revisions[0]["state"] == "cancelled" + + +async def test_worker_verify_mismatch_fails_permanently( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=8, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.return_value = { + "nutritionLog": {"foodDisplayName": "Something else", "energy": {"kcal": 1}} + } + + await worker._dispatch_due() + + row = await storage.nutrition.meal(8) + assert row is not None + assert row["status"] == "failed" + + +async def test_worker_uncertain_upsert_still_processing( + storage: SqliteGoogleHealthStorage, +) -> None: + """A 404 while an upsert is uncertain means it never landed; retry the create.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=9, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthApiError( + "not found", status_code=404 + ) + + await worker._dispatch_due() + + revisions = await storage.nutrition.revisions(9) + assert revisions[0]["state"] == "queued" + row = await storage.nutrition.meal(9) + assert row is not None + assert row["status"] == "pending" + client.create_nutrition_log.assert_not_awaited() + + +async def test_worker_reconciles_persisted_in_flight_upsert( + storage: SqliteGoogleHealthStorage, +) -> None: + """A crash right after "in_flight" persists must not re-POST blindly.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=101, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.revision_state(1, "in_flight") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.return_value = PAYLOAD + + await worker._dispatch_due() + + row = await storage.nutrition.meal(101) + assert row is not None + assert row["status"] == "synced" + revisions = await storage.nutrition.revisions(101) + assert revisions[0]["state"] == "synced" + client.get_data_point.assert_awaited_once() + client.create_nutrition_log.assert_not_awaited() + + +async def test_worker_disconnected_marks_authorization_required( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await storage.nutrition.enqueue( + meal_id=10, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(10) + assert row is not None + assert row["status"] == "authorization_required" + client.refresh_access_token.assert_not_awaited() + + +async def test_worker_start_stop_is_idempotent( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + worker, _client = _worker(storage, config) + await worker.start() + await worker.start() + running_after_start: bool = worker._running + assert running_after_start + await worker.stop() + await worker.stop() + running_after_stop: bool = worker._running + assert not running_after_stop + await worker.close() + + +async def test_worker_stop_without_a_tracked_task_still_clears_running( + storage: SqliteGoogleHealthStorage, +) -> None: + """Defensive: stop() must not crash if ``_task`` was somehow never set.""" + config = _config() + worker, _client = _worker(storage, config) + worker._running = True + worker._task = None + + await worker.stop() + + assert worker._running is False + + +async def test_worker_wake_triggers_immediate_dispatch( + storage: SqliteGoogleHealthStorage, +) -> None: + """``wake()`` must resolve a due meal without waiting for the 60s timer.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=20, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.return_value = GoogleHealthOperation( + done=True, name="op/1", response={} + ) + + await worker.start() + worker.wake() + row = None + for _ in range(50): + row = await storage.nutrition.meal(20) + if row is not None and row["status"] == "synced": + break + await asyncio.sleep(0.05) + await worker.stop() + await worker.close() + + assert row is not None + assert row["status"] == "synced" + client.create_nutrition_log.assert_awaited_once() + + +async def test_worker_token_refresh_transient_failure_backs_off( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=11, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.side_effect = GoogleHealthApiError( + "unavailable", status_code=503 + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(11) + assert row is not None + assert row["status"] == "pending" + assert row["next_attempt"] > 0 + + +async def test_worker_delete_transient_then_confirms_deleted( + storage: SqliteGoogleHealthStorage, +) -> None: + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-z" + ) + await storage.nutrition.enqueue( + meal_id=12, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.delete_nutrition_log.side_effect = GoogleHealthApiError( + "network blip", transport=True + ) + + await worker._dispatch_due() + + revisions = await storage.nutrition.revisions(12) + assert revisions[0]["state"] == "uncertain" + + client.get_data_point.side_effect = GoogleHealthApiError( + "not found", status_code=404 + ) + await storage.nutrition._conn.execute( + "UPDATE nutrition_exports SET next_attempt = 0 WHERE meal_id = 12" + ) + await worker._dispatch_due() + + row = await storage.nutrition.meal(12) + assert row is not None + assert row["status"] == "deleted" + + +def test_nutrition_log_matches_requires_dict_sections() -> None: + from blacki.health.nutrition_worker import _nutrition_log_matches + + assert _nutrition_log_matches({}, {}) is False + assert ( + _nutrition_log_matches({"nutritionLog": PAYLOAD["nutritionLog"]}, PAYLOAD) + is True + ) + + +def test_json_payload_is_preserved_across_backoff_and_retry() -> None: + from blacki.health.nutrition_worker import _safe_error_code + + assert _safe_error_code(None) is None + assert _safe_error_code(ValueError("x")) == "ValueError" + + +async def test_dispatch_due_handles_due_query_failure( + storage: SqliteGoogleHealthStorage, +) -> None: + """A broken local query must not crash the tick, only skip it.""" + config = _config() + worker, _client = _worker(storage, config) + worker.storage.nutrition.due = AsyncMock(side_effect=RuntimeError("db locked")) # type: ignore[method-assign] + + await worker._dispatch_due() # must not raise + + +async def test_dispatch_due_isolates_per_row_failures( + storage: SqliteGoogleHealthStorage, +) -> None: + """One meal raising unexpectedly must not stop the rest of the batch.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=30, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.enqueue( + meal_id=31, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.return_value = GoogleHealthOperation( + done=True, name="op/1", response={} + ) + + original_revisions = storage.nutrition.revisions + + async def _boom(meal_id: int) -> list[dict]: + if meal_id == 30: + raise RuntimeError("unexpected bug") + return await original_revisions(meal_id) + + storage.nutrition.revisions = _boom # type: ignore[method-assign] + + await worker._dispatch_due() # must not raise + + storage.nutrition.revisions = original_revisions # type: ignore[method-assign] + row = await storage.nutrition.meal(31) + assert row is not None + assert row["status"] == "synced" + + +async def test_worker_pauses_on_corrupted_refresh_token( + storage: SqliteGoogleHealthStorage, +) -> None: + """A refresh token that fails decryption must pause, never crash.""" + config = _config() + await storage.upsert_connection( + telegram_user_id=USER_ID, + encrypted_refresh_token="not-a-valid-fernet-token", + health_user_id=HEALTH_USER_ID, + legacy_fitbit_user_id=None, + scopes=GOOGLE_HEALTH_SCOPES, + ) + await storage.nutrition.enqueue( + meal_id=32, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(32) + assert row is not None + assert row["status"] == "authorization_required" + client.refresh_access_token.assert_not_awaited() + connection = await storage.get_connection(USER_ID) + assert connection is not None + assert connection.status == "reauthorization_required" + + +async def test_worker_pauses_when_create_raises_auth_error( + storage: SqliteGoogleHealthStorage, +) -> None: + """An auth error raised mid-dispatch (not during refresh) must pause too.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=33, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.side_effect = GoogleHealthAuthError( + "revoked mid-flight", status_code=401, error_code="invalid_grant" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(33) + assert row is not None + assert row["status"] == "authorization_required" + connection = await storage.get_connection(USER_ID) + assert connection is not None + assert connection.status == "reauthorization_required" + + +async def test_worker_leaves_edit_pending_after_partial_resolution( + storage: SqliteGoogleHealthStorage, +) -> None: + """Resolving the delete half of an edit must not finalize the meal early.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=34, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + old_resource_name = (await storage.nutrition.revisions(34))[0]["resource_name"] + await storage.nutrition.revision_state(1, "synced") + await storage.nutrition.enqueue( + meal_id=34, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=old_resource_name, + ) + await storage.nutrition.enqueue( + meal_id=34, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.delete_nutrition_log.return_value = GoogleHealthOperation( + done=True, name="op/del", response={} + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(34) + assert row is not None + assert row["status"] == "pending" + assert row["next_attempt"] == 0 + client.create_nutrition_log.assert_not_awaited() + revisions = await storage.nutrition.revisions(34) + assert revisions[1]["state"] == "deleted" + assert revisions[2]["state"] == "queued" + + +async def test_worker_create_operation_not_done_marks_uncertain( + storage: SqliteGoogleHealthStorage, +) -> None: + """A create that returns without ``done`` must be retried, not dropped.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=35, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.return_value = GoogleHealthOperation( + done=False, name="op/pending", response=None + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(35) + assert row is not None + assert row["status"] == "pending" + revisions = await storage.nutrition.revisions(35) + assert revisions[0]["state"] == "uncertain" + + +async def test_worker_create_operation_error_marks_failed( + storage: SqliteGoogleHealthStorage, +) -> None: + """A create that completes with a provider error must fail permanently.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=36, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.return_value = GoogleHealthOperation( + done=True, name="op/err", error_code="internal", response=None + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(36) + assert row is not None + assert row["status"] == "failed" + assert row["error_code"] == "internal" + + +async def test_worker_pauses_when_verify_raises_auth_error( + storage: SqliteGoogleHealthStorage, +) -> None: + """An auth error while verifying an uncertain create must pause too.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=37, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthAuthError( + "revoked", status_code=401, error_code="invalid_grant" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(37) + assert row is not None + assert row["status"] == "authorization_required" + + +async def test_worker_verify_transient_error_retries( + storage: SqliteGoogleHealthStorage, +) -> None: + """A transient, non-404 verify failure must be retried, not failed.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=38, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthApiError( + "network blip", transport=True + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(38) + assert row is not None + assert row["status"] == "pending" + assert row["next_attempt"] > 0 + revisions = await storage.nutrition.revisions(38) + assert revisions[0]["state"] == "uncertain" + + +async def test_worker_verify_permanent_error_fails( + storage: SqliteGoogleHealthStorage, +) -> None: + """A non-transient, non-404 verify failure must fail permanently.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=39, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthApiError( + "bad request", status_code=400, error_code="invalid_argument" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(39) + assert row is not None + assert row["status"] == "failed" + assert row["error_code"] == "invalid_argument" + + +async def test_worker_pauses_when_delete_verify_raises_auth_error( + storage: SqliteGoogleHealthStorage, +) -> None: + """An auth error while confirming an uncertain delete must pause too.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-a" + ) + await storage.nutrition.enqueue( + meal_id=40, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthAuthError( + "revoked", status_code=401, error_code="invalid_grant" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(40) + assert row is not None + assert row["status"] == "authorization_required" + + +async def test_worker_delete_verify_transient_error_retries( + storage: SqliteGoogleHealthStorage, +) -> None: + """A transient, non-404 delete-verify failure must be retried, not failed.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-b" + ) + await storage.nutrition.enqueue( + meal_id=41, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthApiError( + "network blip", transport=True + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(41) + assert row is not None + assert row["status"] == "pending" + assert row["next_attempt"] > 0 + revisions = await storage.nutrition.revisions(41) + assert revisions[0]["state"] == "uncertain" + + +async def test_worker_delete_verify_permanent_error_fails( + storage: SqliteGoogleHealthStorage, +) -> None: + """A non-transient, non-404 delete-verify failure must fail permanently.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-c" + ) + await storage.nutrition.enqueue( + meal_id=42, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.side_effect = GoogleHealthApiError( + "bad request", status_code=400, error_code="invalid_argument" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(42) + assert row is not None + assert row["status"] == "failed" + assert row["error_code"] == "invalid_argument" + + +async def test_worker_delete_verify_success_requeues_delete( + storage: SqliteGoogleHealthStorage, +) -> None: + """If the point still exists on confirmation, the delete must be retried.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-d" + ) + await storage.nutrition.enqueue( + meal_id=43, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + await storage.nutrition.revision_state(1, "uncertain") + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.get_data_point.return_value = {"nutritionLog": {}} + + await worker._dispatch_due() + + row = await storage.nutrition.meal(43) + assert row is not None + assert row["status"] == "pending" + revisions = await storage.nutrition.revisions(43) + assert revisions[0]["state"] == "queued" + client.delete_nutrition_log.assert_not_awaited() + + +async def test_worker_pauses_when_delete_raises_auth_error( + storage: SqliteGoogleHealthStorage, +) -> None: + """An auth error raised directly by the delete call must pause too.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-e" + ) + await storage.nutrition.enqueue( + meal_id=44, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.delete_nutrition_log.side_effect = GoogleHealthAuthError( + "revoked", status_code=401, error_code="invalid_grant" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(44) + assert row is not None + assert row["status"] == "authorization_required" + + +async def test_worker_delete_404_treated_as_already_deleted( + storage: SqliteGoogleHealthStorage, +) -> None: + """A 404 straight from the delete call means the point is already gone.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-f" + ) + await storage.nutrition.enqueue( + meal_id=45, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.delete_nutrition_log.side_effect = GoogleHealthApiError( + "not found", status_code=404 + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(45) + assert row is not None + assert row["status"] == "deleted" + + +async def test_worker_delete_permanent_failure( + storage: SqliteGoogleHealthStorage, +) -> None: + """A non-transient, non-404 delete failure must fail permanently.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-g" + ) + await storage.nutrition.enqueue( + meal_id=46, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.delete_nutrition_log.side_effect = GoogleHealthApiError( + "bad request", status_code=400, error_code="invalid_argument" + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(46) + assert row is not None + assert row["status"] == "failed" + assert row["error_code"] == "invalid_argument" + + +async def test_worker_delete_operation_not_done_marks_uncertain( + storage: SqliteGoogleHealthStorage, +) -> None: + """A delete that returns without ``done`` must be retried, not dropped.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-h" + ) + await storage.nutrition.enqueue( + meal_id=47, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.delete_nutrition_log.return_value = GoogleHealthOperation( + done=False, name="op/pending", response=None + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(47) + assert row is not None + assert row["status"] == "pending" + revisions = await storage.nutrition.revisions(47) + assert revisions[0]["state"] == "uncertain" + + +async def test_worker_delete_operation_error_marks_failed( + storage: SqliteGoogleHealthStorage, +) -> None: + """A delete that completes with a provider error must fail permanently.""" + config = _config() + await _connect(storage, config) + resource_name = ( + f"users/{HEALTH_USER_ID}/dataTypes/nutrition-log/dataPoints/blacki-i" + ) + await storage.nutrition.enqueue( + meal_id=48, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=None, + operation="delete", + target_resource_name=resource_name, + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.delete_nutrition_log.return_value = GoogleHealthOperation( + done=True, name="op/err", error_code="internal", response=None + ) + + await worker._dispatch_due() + + row = await storage.nutrition.meal(48) + assert row is not None + assert row["status"] == "failed" + assert row["error_code"] == "internal" + + +async def test_backoff_honors_retry_after_header( + storage: SqliteGoogleHealthStorage, +) -> None: + """A server-provided Retry-After must extend the backoff, not shrink it.""" + config = _config() + await _connect(storage, config) + await storage.nutrition.enqueue( + meal_id=49, + owner_id=USER_ID, + telegram_user_id=USER_ID, + health_user_id=HEALTH_USER_ID, + payload=PAYLOAD, + operation="upsert", + ) + worker, client = _worker(storage, config) + client.refresh_access_token.return_value = _token() + client.create_nutrition_log.side_effect = GoogleHealthApiError( + "rate limited", status_code=429, retry_after_seconds=900 + ) + + before = datetime.now(UTC).timestamp() + await worker._dispatch_due() + + row = await storage.nutrition.meal(49) + assert row is not None + assert row["status"] == "pending" + assert row["next_attempt"] >= before + 900 diff --git a/tests/test_privacy.py b/tests/test_privacy.py index 15d830b..4f55696 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -55,6 +55,20 @@ def test_private_tool_identification_uses_zepto_prefix() -> None: assert is_private_tool(_tool("get_health_summary")) is True +@pytest.mark.parametrize( + "tool_name", + [ + "log_meal", + "edit_meal", + "delete_meal", + "get_calorie_summary", + "set_calorie_goal", + ], +) +def test_calorie_tools_are_private(tool_name: str) -> None: + assert is_private_tool(_tool(tool_name)) is True + + def test_kokoro_tts_enables_content_redaction( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -169,6 +183,40 @@ async def test_privacy_logging_plugin_redacts_tts_payloads( assert "private speech" not in output +@pytest.mark.asyncio +async def test_privacy_logging_plugin_redacts_calorie_payloads( + capsys: pytest.CaptureFixture[str], +) -> None: + plugin = PrivacyAwareLoggingPlugin() + context = MagicMock() + context.function_call_id = "call-meal" + context.agent_name = "blacki" + tool = _tool("log_meal") + + await plugin.before_tool_callback( + tool=tool, + tool_args={"description": "private dinner", "estimated_calories": 900}, + tool_context=context, + ) + await plugin.after_tool_callback( + tool=tool, + tool_args={"description": "private dinner", "estimated_calories": 900}, + tool_context=context, + result={"message": "private dinner", "daily_total": 900}, + ) + await plugin.on_tool_error_callback( + tool=tool, + tool_args={"description": "private dinner", "estimated_calories": 900}, + tool_context=context, + error=RuntimeError("private meal failure"), + ) + + output = capsys.readouterr().out + assert "log_meal" in output + for private_value in ("private dinner", "900", "private meal failure"): + assert private_value not in output + + @pytest.mark.asyncio async def test_privacy_logging_plugin_preserves_normal_tool_logging( capsys: pytest.CaptureFixture[str], diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 9233b20..866de27 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -131,6 +131,18 @@ def test_health_policy_is_enabled_only_for_health_tool() -> None: assert "never infer, diagnose" in instruction +def test_nutrition_policy_separates_local_save_and_google_sync() -> None: + instruction = build_domain_instruction( + "Log my lunch", {"log_meal", "edit_meal", "delete_meal"} + ) + + assert "google_health_sync status separately" in instruction + assert "saved in Blacki" in instruction + assert "Never claim Google Health accepted" in instruction + assert "Do not repeat a meal mutation" in instruction + assert "no historical backfill" in instruction + + class TestDomainPolicyAssembly: """Verify conditional policy content for behavior-sensitive requests.""" diff --git a/tests/test_server_config.py b/tests/test_server_config.py index 9acfd72..a099dcb 100644 --- a/tests/test_server_config.py +++ b/tests/test_server_config.py @@ -737,3 +737,90 @@ async def test_google_health_stop_suppresses_scheduler_and_client_errors( await server._stop_google_health() assert getattr(server, "_google_health_scheduler", object()) is None assert getattr(server, "_google_health_service", object()) is None + + +@pytest.mark.asyncio +async def test_google_health_export_worker_start_failure_rolls_back( + mock_dependencies: MagicMock, +) -> None: + """A failing export worker must not leave the scheduler/service dangling.""" + if "blacki.server" in sys.modules: + del sys.modules["blacki.server"] + + from cryptography.fernet import Fernet + + import blacki.server as server + from blacki.health.config import GoogleHealthConfig + from blacki.health.nutrition_worker import NutritionExportWorker + from blacki.health.scheduler import GoogleHealthScheduler + + server._container = MagicMock() + config = GoogleHealthConfig( + client_id="id", + client_secret="secret", + redirect_uri="https://example.test/callback", + token_encryption_key=Fernet.generate_key().decode(), + ) + with ( + patch( + "blacki.health.config.GoogleHealthConfig.from_environment", + return_value=config, + ), + patch.object(GoogleHealthScheduler, "start", new=AsyncMock()), + patch.object(GoogleHealthScheduler, "stop", new=AsyncMock()), + patch.object( + NutritionExportWorker, + "start", + new=AsyncMock(side_effect=RuntimeError("worker boot failure")), + ), + patch.object(NutritionExportWorker, "close", new=AsyncMock()), + ): + await server._start_google_health() + + assert server._google_health_service is None + assert server._google_health_scheduler is None + assert server._google_health_export_worker is None + server._container = None + + +@pytest.mark.asyncio +async def test_google_health_stop_without_container_skips_clearing_worker_ref( + mock_dependencies: MagicMock, +) -> None: + """Shutdown must not crash if the container was already torn down.""" + if "blacki.server" in sys.modules: + del sys.modules["blacki.server"] + + import blacki.server as server + + server._container = None + worker = MagicMock() + worker.stop = AsyncMock() + worker.close = AsyncMock() + server._google_health_export_worker = worker + + await server._stop_google_health() + + worker.stop.assert_awaited_once() + assert getattr(server, "_google_health_export_worker", object()) is None + + +@pytest.mark.asyncio +async def test_google_health_stop_suppresses_export_worker_errors( + mock_dependencies: MagicMock, +) -> None: + """Shutdown clears the export worker global even if it fails to stop.""" + if "blacki.server" in sys.modules: + del sys.modules["blacki.server"] + + import blacki.server as server + + server._container = MagicMock() + worker = MagicMock() + worker.stop = AsyncMock(side_effect=RuntimeError("worker")) + server._google_health_export_worker = worker + + await server._stop_google_health() + + assert getattr(server, "_google_health_export_worker", object()) is None + server._container = None diff --git a/tests/test_telegram_health.py b/tests/test_telegram_health.py index 15765b8..e96295f 100644 --- a/tests/test_telegram_health.py +++ b/tests/test_telegram_health.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import SimpleNamespace from typing import cast from unittest.mock import AsyncMock, MagicMock, create_autospec @@ -15,7 +16,11 @@ ) from blacki.telegram import TelegramConfig from blacki.telegram.api import TelegramApiClient -from blacki.telegram.bot import TelegramBot, _format_health_sync_result +from blacki.telegram.bot import ( + TelegramBot, + _format_google_health_sync_counts, + _format_health_sync_result, +) from blacki.telegram.types import CallbackQuery, ChatType, Message @@ -77,6 +82,10 @@ async def test_connect_health_sends_protected_authorization_link() -> None: .inline_keyboard[0][0] .url.startswith("https://accounts.google.com/") ) + assert "future meal logs, edits, and deletions" in kwargs["text"] + assert "not backfilled" in kwargs["text"] + assert "verify records it created" in kwargs["text"] + assert "Read-only summaries remain available" in kwargs["text"] assert "Apple ID" in kwargs["text"] @@ -125,6 +134,23 @@ async def test_health_summary_and_refresh_are_readable() -> None: ) +@pytest.mark.asyncio +async def test_health_refresh_success_includes_sync_counts() -> None: + """A successful refresh still reports durable meal-export counts.""" + bot, service, api = _bot() + service.refresh_user.return_value = SyncResult( + status="success", + telegram_user_id="telegram-chat-42", + days_upserted=1, + records_fetched=2, + google_health_sync={"pending": 1, "synced": 3}, + ) + await bot._handle_command(_message(), "/health_refresh") + text = api.send_message.call_args.kwargs["text"] + assert "1 pending" in text + assert "3 synced" in text + + @pytest.mark.asyncio async def test_health_refresh_status_messages() -> None: """Non-success sync states remain provider- and identity-safe.""" @@ -182,7 +208,11 @@ async def test_disconnect_requires_confirmation_and_checks_callback_user() -> No ) await bot._handle_callback_query(confirmed) service.disconnect.assert_awaited_once_with("telegram-chat-42") - assert "deleted" in api.send_message.call_args.kwargs["text"] + text = api.send_message.call_args.kwargs["text"] + assert "Pending meal sync was cancelled" in text + assert "local calorie logs remain" in text + assert "did not delete records already sent" in text + assert "may still finish" in text @pytest.mark.asyncio @@ -293,3 +323,31 @@ def test_health_sync_formatter_covers_safe_statuses() -> None: assert "could not" in _format_health_sync_result( SyncResult("failed", "telegram-chat-42") ) + result_with_counts = cast( + SyncResult, + SimpleNamespace( + status="failed", + google_health_sync={ + "pending": 2, + "synced": 3, + "failed": 1, + "authorization_required": 4, + }, + ), + ) + text = _format_health_sync_result(result_with_counts) + assert "2 pending" in text + assert "3 synced" in text + assert "1 failed" in text + assert "4 awaiting authorization" in text + assert "pending includes deletions" in text + + +def test_format_google_health_sync_counts_skips_invalid_entries() -> None: + """Non-mapping input, missing keys, and negative counts are all safe.""" + assert _format_google_health_sync_counts("not-a-mapping") == "" + assert _format_google_health_sync_counts({}) == "" + assert _format_google_health_sync_counts({"pending": -1, "synced": "bad"}) == "" + + partial = _format_google_health_sync_counts({"pending": 2, "synced": "bad"}) + assert partial == "Meal sync status (pending includes deletions): 2 pending"