Add Baby Tracker client and Home Assistant integration

Python client for the Baby Tracker sync server, reverse engineered from
the Android app (4.53), and a HACS-installable Home Assistant integration
built on it. Each baby gets a log calendar and summary sensors, and actions
add entries that sync back to the group.

Reading has been run against the real server. Uploading and running inside
real Home Assistant have not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
arneandClaude Opus 5.5 committed 2026-11-04 10:04:39 +00:00
commit 6466f002e0
51 files changed
+5199

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
A minimal stand-in for the parts of Home Assistant this integration uses, so its
code can run in tests without installing Home Assistant.
It only shows that the integration's own logic works against these fakes. It does not
show that the integration works in real Home Assistant.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,24 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from ...exceptions import HomeAssistantError
@dataclass
class CalendarEvent:
start: datetime
end: datetime
summary: str
description: Optional[str] = None
uid: Optional[str] = None
def __post_init__(self):
if self.start.tzinfo is None or self.end.tzinfo is None:
raise HomeAssistantError("Expected all values to have a timezone")
if self.end <= self.start:
raise HomeAssistantError("Expected end time to be after start time")
class CalendarEntity:
pass
@@ -0,0 +1,30 @@
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional
class SensorDeviceClass(str, Enum):
DISTANCE = "distance"
DURATION = "duration"
TEMPERATURE = "temperature"
TIMESTAMP = "timestamp"
VOLUME = "volume"
WEIGHT = "weight"
class SensorStateClass(str, Enum):
MEASUREMENT = "measurement"
TOTAL_INCREASING = "total_increasing"
@dataclass(frozen=True, kw_only=True)
class SensorEntityDescription:
key: str
device_class: Optional[SensorDeviceClass] = None
state_class: Optional[SensorStateClass] = None
native_unit_of_measurement: Optional[Any] = None
suggested_display_precision: Optional[int] = None
class SensorEntity:
pass
@@ -0,0 +1,63 @@
from enum import Enum
from typing import Generic, TypeVar
from .data_entry_flow import AbortFlow
_T = TypeVar("_T")
ConfigFlowResult = dict
class ConfigEntryState(Enum):
LOADED = "loaded"
NOT_LOADED = "not_loaded"
class ConfigEntry(Generic[_T]):
def __init__(self, data, unique_id, title="entry"):
self.data = dict(data)
self.unique_id = unique_id
self.title = title
self.state = ConfigEntryState.NOT_LOADED
self.runtime_data = None
self.unloads = []
def async_on_unload(self, func):
self.unloads.append(func)
class ConfigFlow:
def __init_subclass__(cls, domain=None, **kwargs):
super().__init_subclass__(**kwargs)
cls.domain = domain
def __init__(self):
self.hass = None
self.unique_id = None
self.existing_unique_ids = set()
self.reauth_entry = None
self.updated = None
def async_show_form(self, step_id, data_schema=None, errors=None, description_placeholders=None):
return {"type": "form", "step_id": step_id, "errors": errors or {}, "schema": data_schema,
"description_placeholders": description_placeholders}
def async_create_entry(self, title, data):
return {"type": "create_entry", "title": title, "data": data}
async def async_set_unique_id(self, unique_id):
self.unique_id = unique_id
def _abort_if_unique_id_configured(self):
if self.unique_id in self.existing_unique_ids:
raise AbortFlow("already_configured")
def _abort_if_unique_id_mismatch(self, reason="unique_id_mismatch"):
if self.reauth_entry.unique_id != self.unique_id:
raise AbortFlow(reason)
def _get_reauth_entry(self):
return self.reauth_entry
def async_update_reload_and_abort(self, entry, data_updates=None):
entry.data.update(data_updates or {})
return {"type": "abort", "reason": "reauth_successful"}
+29
View File
@@ -0,0 +1,29 @@
from enum import Enum
CONF_EMAIL = "email"
CONF_PASSWORD = "password"
class Platform(str, Enum):
CALENDAR = "calendar"
SENSOR = "sensor"
class UnitOfLength(str, Enum):
CENTIMETERS = "cm"
class UnitOfMass(str, Enum):
KILOGRAMS = "kg"
class UnitOfTemperature(str, Enum):
CELSIUS = "°C"
class UnitOfTime(str, Enum):
MINUTES = "min"
class UnitOfVolume(str, Enum):
MILLILITERS = "mL"
+50
View File
@@ -0,0 +1,50 @@
import asyncio
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional
ServiceResponse = Optional[dict]
def callback(func):
return func
class SupportsResponse(Enum):
NONE = "none"
OPTIONAL = "optional"
ONLY = "only"
@dataclass
class ServiceCall:
data: dict = field(default_factory=dict)
return_response: bool = False
class _Services:
def __init__(self):
self.registered = {}
def async_register(self, domain, service, handler, schema=None, supports_response=SupportsResponse.NONE):
self.registered[(domain, service)] = (handler, schema, supports_response)
async def async_call(self, domain, service, data=None, return_response=False):
handler, schema, _ = self.registered[(domain, service)]
validated = schema(dict(data or {})) if schema else dict(data or {})
return await handler(ServiceCall(validated, return_response))
class _Config:
def __init__(self, units):
self.units = units
class HomeAssistant:
def __init__(self, units):
self.config = _Config(units)
self.services = _Services()
self.config_entries = None
async def async_add_executor_job(self, target, *args):
return await asyncio.get_running_loop().run_in_executor(None, target, *args)
@@ -0,0 +1,4 @@
class AbortFlow(Exception):
def __init__(self, reason):
super().__init__(reason)
self.reason = reason
@@ -0,0 +1,18 @@
class HomeAssistantError(Exception):
def __init__(self, *args, translation_domain=None, translation_key=None, translation_placeholders=None):
super().__init__(*args)
self.translation_domain = translation_domain
self.translation_key = translation_key
self.translation_placeholders = translation_placeholders
class ServiceValidationError(HomeAssistantError):
pass
class ConfigEntryAuthFailed(HomeAssistantError):
pass
class ConfigEntryNotReady(HomeAssistantError):
pass
Whitespace-only changes.
@@ -0,0 +1,22 @@
from datetime import datetime as datetime_sys
import voluptuous
def string(value):
if value is None:
raise voluptuous.Invalid("string value is None")
return str(value)
def datetime(value):
if isinstance(value, datetime_sys):
return value
try:
return datetime_sys.fromisoformat(value)
except (TypeError, ValueError) as err:
raise voluptuous.Invalid("invalid datetime") from err
def config_entry_only_config_schema(domain):
return lambda config: config
@@ -0,0 +1,30 @@
from dataclasses import dataclass
from enum import Enum
class DeviceEntryType(Enum):
SERVICE = "service"
def DeviceInfo(**kwargs):
return kwargs
@dataclass
class DeviceEntry:
identifiers: set
class DeviceRegistry:
def __init__(self):
self.devices = {}
def async_get(self, device_id):
return self.devices.get(device_id)
REGISTRY = DeviceRegistry()
def async_get(hass):
return REGISTRY
@@ -0,0 +1,18 @@
from enum import Enum
class TextSelectorType(str, Enum):
EMAIL = "email"
PASSWORD = "password"
def TextSelectorConfig(**kwargs):
return kwargs
class TextSelector:
def __init__(self, config=None):
self.config = config
def __call__(self, value):
return str(value)
@@ -0,0 +1 @@
ConfigType = dict
@@ -0,0 +1,60 @@
from typing import Generic, TypeVar
from ..exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError
_T = TypeVar("_T")
class UpdateFailed(HomeAssistantError):
pass
class DataUpdateCoordinator(Generic[_T]):
def __init__(self, hass, logger, *, config_entry=None, name=None, update_interval=None):
self.hass = hass
self.logger = logger
self.config_entry = config_entry
self.name = name
self.update_interval = update_interval
self.data = None
self.last_update_success = True
self.last_exception = None
self._listeners = []
def async_add_listener(self, listener):
self._listeners.append(listener)
return lambda: self._listeners.remove(listener)
def _notify(self):
for listener in list(self._listeners):
listener()
async def async_refresh(self):
try:
self.data = await self._async_update_data()
except UpdateFailed as err:
self.last_update_success = False
self.last_exception = err
else:
self.last_update_success = True
self._notify()
async def async_config_entry_first_refresh(self):
try:
self.data = await self._async_update_data()
except UpdateFailed as err:
raise ConfigEntryNotReady(str(err)) from err
def async_set_updated_data(self, data):
self.data = data
self.last_update_success = True
self._notify()
class CoordinatorEntity(Generic[_T]):
def __init__(self, coordinator):
self.coordinator = coordinator
@property
def available(self):
return self.coordinator.last_update_success
Whitespace-only changes.
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime, timezone
DEFAULT_TIME_ZONE = timezone.utc
def utcnow():
return datetime.now(timezone.utc)
def as_utc(value):
if value.tzinfo is None:
value = value.replace(tzinfo=DEFAULT_TIME_ZONE)
return value.astimezone(timezone.utc)
def as_local(value):
return value.astimezone(DEFAULT_TIME_ZONE)
def start_of_local_day():
return utcnow().astimezone(DEFAULT_TIME_ZONE).replace(hour=0, minute=0, second=0, microsecond=0)
@@ -0,0 +1,6 @@
class UnitSystem:
pass
METRIC_SYSTEM = UnitSystem()
US_CUSTOMARY_SYSTEM = UnitSystem()
+101
View File
@@ -0,0 +1,101 @@
"""Just enough of voluptuous for the integration's schemas."""
class Invalid(Exception):
pass
class _Marker:
def __init__(self, key, default=None):
self.key = key
self.default = default
def __hash__(self):
return hash(self.key)
def __eq__(self, other):
return self.key == getattr(other, "key", other)
class Required(_Marker):
pass
class Optional(_Marker):
pass
def _check(validator, value):
if isinstance(validator, type):
if not isinstance(value, validator):
raise Invalid(f"expected {validator.__name__}")
return value
return validator(value)
class Schema:
def __init__(self, schema):
self.schema = schema
def __call__(self, data):
result = {}
known = set()
for marker, validator in self.schema.items():
known.add(marker.key)
if marker.key in data:
result[marker.key] = _check(validator, data[marker.key])
elif isinstance(marker, Required):
raise Invalid(f"required key not provided: {marker.key}")
elif marker.default is not None:
result[marker.key] = marker.default
extra = set(data) - known
if extra:
raise Invalid(f"extra keys not allowed: {sorted(extra)}")
return result
class All:
def __init__(self, *validators):
self.validators = validators
def __call__(self, value):
for validator in self.validators:
value = _check(validator, value)
return value
def In(options):
def validate(value):
if value not in options:
raise Invalid(f"{value} not in {options}")
return value
return validate
def Coerce(kind):
def validate(value):
try:
return kind(value)
except (TypeError, ValueError) as err:
raise Invalid(str(err)) from err
return validate
def Range(min=None):
def validate(value):
if min is not None and value < min:
raise Invalid(f"{value} below {min}")
return value
return validate
def Length(min=None):
def validate(value):
if min is not None and len(value) < min:
raise Invalid("too short")
return value
return validate
+20
View File
@@ -0,0 +1,20 @@
"""Makes ``import babytracker_api`` work without Home Assistant.
The library is loaded by path because the integration folder holds a calendar.py,
which would hide Python's own calendar module if that folder were on sys.path.
"""
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
LIBRARY = ROOT / "custom_components" / "babytracker" / "babytracker_api"
if "babytracker_api" not in sys.modules:
spec = importlib.util.spec_from_file_location(
"babytracker_api", LIBRARY / "__init__.py", submodule_search_locations=[str(LIBRARY)]
)
module = importlib.util.module_from_spec(spec)
sys.modules["babytracker_api"] = module
spec.loader.exec_module(module)
+178
View File
@@ -0,0 +1,178 @@
"""In-process fake of the sync server, written from the app's client code.
It checks that this client speaks the protocol as the decompiled app does. It
cannot show that the real server behaves as the app expects.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Optional
COOKIE = "session=abc123"
class MockState:
def __init__(self) -> None:
self.account_id = 42
self.email = "parent@example.com"
self.password = "correct-horse"
self.valid_sessions = True
self.login_status: Optional[int] = None
self.devices: list[dict[str, Any]] = []
self.logs: dict[str, list[dict[str, Any]]] = {}
self.uploads: list[dict[str, Any]] = []
self.files: dict[str, bytes] = {}
self.seed: Optional[bytes] = None
self.requests: list[tuple[str, str]] = []
self.login_bodies: list[dict[str, Any]] = []
self.multipart: dict[str, tuple[str, bytes]] = {}
# Make uploads appear in the uploader's log, as a real server would.
self.log_uploads = False
self.fail_with: Optional[int] = None
class Handler(BaseHTTPRequestHandler):
server: "MockServer"
def log_message(self, *args: Any) -> None:
pass
def _reply(self, status: int, body: Any = None, headers: Optional[dict[str, str]] = None) -> None:
payload = b""
if isinstance(body, bytes):
payload = body
elif body is not None:
payload = json.dumps(body).encode("utf-8")
self.send_response(status)
for key, value in (headers or {}).items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _body(self) -> bytes:
return self.rfile.read(int(self.headers.get("Content-Length") or 0))
def _authorized(self) -> bool:
state = self.server.state
if state.valid_sessions and COOKIE in (self.headers.get("Cookie") or ""):
return True
self._reply(401, b"unauthorized")
return False
def _login(self, body: bytes) -> None:
state = self.server.state
data = json.loads(body)
state.login_bodies.append(data)
if state.login_status:
self._reply(state.login_status, b"nope", {"Location": "/redirected"})
return
if data["EmailAddress"] != state.email or data["Password"] != state.password:
self._reply(401, b"bad credentials")
return
state.valid_sessions = True
self._reply(200, {"AccountID": state.account_id}, {"Set-Cookie": f"{COOKIE}; Path=/"})
def do_POST(self) -> None:
state = self.server.state
state.requests.append(("POST", self.path))
body = self._body()
if state.fail_with and self.path != "/session":
self._reply(state.fail_with)
return
if self.path == "/session":
self._login(body)
elif self.path == "/redirected":
self._reply(200, {"AccountID": 666})
elif self.path == "/account/forgot_password":
self._reply(200)
elif self.path in ("/staging_account", "/account_reset"):
state.login_bodies.append(json.loads(body))
if json.loads(body)["EmailAddress"] == "taken@example.com":
self._reply(409, b"exists")
else:
self._reply(200, None, {"Set-Cookie": f"{COOKIE}; Path=/"})
elif not self._authorized():
return
elif self.path == "/device_token":
state.multipart["token"] = ("", body)
self._reply(201)
elif self.path == "/account/transaction":
data = json.loads(body)
state.uploads.append(data)
if state.log_uploads:
uuid = state.login_bodies[-1]["Device"]["DeviceUUID"]
state.logs.setdefault(uuid, []).append(data)
state.devices = [d for d in state.devices if d["DeviceUUID"] != uuid]
state.devices.append({"DeviceUUID": uuid, "LastSyncID": data["SyncID"]})
self._reply(201)
elif self.path in ("/account/picture", "/staging_account/picture", "/staging_account/seed", "/account/clonedata"):
state.multipart[self.path] = (self.headers.get("Content-Type", ""), body)
self._reply(201)
else:
self._reply(404)
def do_PUT(self) -> None:
state = self.server.state
state.requests.append(("PUT", self.path))
self._body()
if self.path != "/staging_account":
self._reply(404)
elif "/staging_account/seed" not in state.multipart:
self._reply(424, b"no seed")
elif self._authorized():
self._reply(200, {"AccountID": state.account_id})
def do_DELETE(self) -> None:
self.server.state.requests.append(("DELETE", self.path))
if self._authorized():
self._reply(200)
def do_GET(self) -> None:
state = self.server.state
state.requests.append(("GET", self.path))
if state.fail_with:
self._reply(state.fail_with)
return
if not self._authorized():
return
parts = self.path.strip("/").split("/")
if self.path == "/account/device":
self._reply(200, state.devices)
elif parts[:2] == ["account", "transaction"] and len(parts) == 4:
log = state.logs.get(parts[2], [])
after = int(parts[3])
self._reply(200, [item for item in log if item["SyncID"] > after][:500])
elif self.path == "/account/seed":
if state.seed is None:
self._reply(404, b"no seed")
else:
self._reply(200, state.seed, {"Content-Disposition": "attachment; filename=seed.zip"})
elif parts[:2] == ["account", "picture"] and parts[2] in state.files:
self._reply(200, state.files[parts[2]], {"Content-Disposition": f"attachment; filename={parts[2]}"})
else:
self._reply(404)
class MockServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self) -> None:
super().__init__(("127.0.0.1", 0), Handler)
self.state = MockState()
self._thread = threading.Thread(target=self.serve_forever, args=(0.01,), daemon=True)
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.server_address[1]}"
def __enter__(self) -> "MockServer":
self._thread.start()
return self
def __exit__(self, *args: Any) -> None:
self.shutdown()
self.server_close()
+81
View File
@@ -0,0 +1,81 @@
"""Builds a seed database using table definitions taken from the app (4.53)."""
import sqlite3
import zipfile
from pathlib import Path
_ID = '"ID" TEXT PRIMARY KEY NOT NULL UNIQUE, "Timestamp" REAL DEFAULT 0'
_ACTIVITY = f'{_ID}, "Time" REAL NOT NULL, "Note" TEXT, "HasPicture" INTEGER'
SCHEMA = f"""
CREATE TABLE "Baby" ({_ID}, "Name" TEXT NOT NULL UNIQUE, "DOB" REAL, "DueDay" REAL, "Gender" INTEGER, "Picture" TEXT);
CREATE TABLE "MergedTransaction" ("DeviceID" TEXT PRIMARY KEY NOT NULL UNIQUE, "SyncID" INTEGER);
CREATE TABLE "Diaper" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Status" INTEGER NOT NULL, "PeeColor" INTEGER DEFAULT 5,
"PooColor" INTEGER DEFAULT 5, "Amount" INTEGER DEFAULT 5, "Texture" INTEGER DEFAULT 5, "Flag" INTEGER DEFAULT 0);
CREATE TABLE "Sleep" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Duration" INTEGER DEFAULT 0, "LocationID" TEXT, "DescID" TEXT);
CREATE TABLE "Formula" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "DescID" TEXT, "Amount" REAL NOT NULL, "IsEnglishScale" INTEGER NOT NULL);
CREATE TABLE "Pumped" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "DescID" TEXT, "Amount" REAL NOT NULL, "IsEnglishScale" INTEGER NOT NULL, "PumpID" TEXT);
CREATE TABLE "Nursing" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "DescID" TEXT, "FinishSide" INTEGER, "LeftDuration" INTEGER DEFAULT 0,
"RightDuration" INTEGER DEFAULT 0, "BothDuration" INTEGER DEFAULT 0);
CREATE TABLE "Temperature" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Temp" REAL NOT NULL, "IsEnglishScale" INTEGER NOT NULL);
CREATE TABLE "Growth" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Weight" REAL DEFAULT 0, "Length" REAL DEFAULT 0, "Head" READ DEFAULT 0,
"IsEnglishLengthScale" INTEGER DEFAULT 1, "IsEnglishWeightScale" INTEGER DEFAULT 1);
CREATE TABLE "Medicine" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "MedID" TEXT NOT NULL, "Amount" REAL);
CREATE TABLE "MedicineSelection" ({_ID}, "Name" TEXT NOT NULL UNIQUE, "Description" TEXT, "IsPrescription" INTEGER,
"AmountPerTime" REAL, "Unit" TEXT, "Interval" INTEGER);
CREATE TABLE "Pump" ({_ACTIVITY}, "FinishSide" INTEGER, "Sides" INTEGER, "Amount" REAL, "IsEnglishScale" INTEGER, "Label" TEXT, "PumpedID" TEXT);
CREATE TABLE "Sick" ({_ACTIVITY}, "BabyID" TEXT NOT NULL, "Symptom" TEXT);
"""
BABY_ID = "baby-1"
def build_seed_db(path: Path, now: float) -> Path:
"""Write a database with one baby, Sam, whose entries are relative to ``now``."""
connection = sqlite3.connect(path)
connection.executescript(SCHEMA)
connection.execute(
'INSERT INTO "Baby" VALUES (?, ?, ?, ?, ?, ?, ?)', (BABY_ID, now - 86400, "Sam", now - 90 * 86400, 0, 1, None)
)
connection.execute('INSERT INTO "MergedTransaction" VALUES (?, ?)', ("phone", 3))
hour = 3600
rows = {
'"Diaper" ("ID","Timestamp","Time","Note","BabyID","Status","Amount")': [
("d-old", now, now - 40 * hour, "", BABY_ID, 0, 2),
("d-dry", now, now - 2 * hour, "", BABY_ID, 0, 0),
("d-new", now, now - 60, "messy", BABY_ID, 1, 3),
],
'"Sleep" ("ID","Timestamp","Time","BabyID","Duration")': [("s-1", now, now - 3 * hour, BABY_ID, 45)],
'"Formula" ("ID","Timestamp","Time","BabyID","Amount","IsEnglishScale")': [
("f-oz", now, now - 120, BABY_ID, 4, 1),
("f-ml", now, now - 180, BABY_ID, 100, 0),
],
'"Nursing" ("ID","Timestamp","Time","BabyID","FinishSide","LeftDuration","RightDuration")': [
("n-1", now, now - 5 * hour, BABY_ID, 2, 5, 7)
],
'"Temperature" ("ID","Timestamp","Time","BabyID","Temp","IsEnglishScale")': [
("t-1", now, now - 6 * hour, BABY_ID, 98.6, 1)
],
'"Growth" ("ID","Timestamp","Time","BabyID","Weight","Length","Head","IsEnglishLengthScale","IsEnglishWeightScale")': [
("g-old", now, now - 50 * hour, BABY_ID, 5.5, 60, 40, 0, 0),
("g-new", now, now - 7 * hour, BABY_ID, 13.2277, 0, 0, 1, 1),
],
'"MedicineSelection" ("ID","Timestamp","Name")': [("med-1", now, "Vitamin D")],
'"Medicine" ("ID","Timestamp","Time","BabyID","MedID","Amount")': [("m-1", now, now - 8 * hour, BABY_ID, "med-1", 1)],
'"Pump" ("ID","Timestamp","Time","Amount","IsEnglishScale")': [("p-1", now, now - 9 * hour, 80, 0)],
'"Sick" ("ID","Timestamp","Time","BabyID","Symptom")': [("k-1", now, now - 10 * hour, BABY_ID, "cough")],
}
for target, values in rows.items():
marks = ",".join("?" * len(values[0]))
connection.executemany(f"INSERT INTO {target} VALUES ({marks})", values)
connection.commit()
connection.close()
return path
def build_seed_zip(folder: Path, now: float) -> bytes:
db = build_seed_db(folder / "fixture.db", now)
zip_path = folder / "fixture.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
archive.write(db, "EasyLog.db")
return zip_path.read_bytes()
+392
View File
@@ -0,0 +1,392 @@
import base64
import json
import sqlite3
import sys
import tempfile
import unittest
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import load_library # noqa: F401
from mock_server import MockServer
from babytracker_api import (
AccountChangedError,
AccountStateError,
AuthenticationError,
BabyTrackerClient,
ConflictError,
DiaperAmount,
DiaperStatus,
MissingSeedError,
NotFoundError,
NursingFinishSide,
OpCode,
Transaction,
decode_object,
diaper,
encode_object,
extract_seed_db,
format_date,
formula,
growth,
new_activity,
nursing,
open_seed_db,
parse_date,
read_babies,
read_cursors,
sleep,
)
BABY = {"objectID": "baby-1", "name": "Sam", "gender": True, "newFlage": False}
def log_entry(sync_id, op="0"):
payload = encode_object({"BCObjectType": "Bath", "objectID": f"o{sync_id}"})
return {"SyncID": sync_id, "OPCode": op, "Transaction": payload}
class ServerTestCase(unittest.TestCase):
def setUp(self):
self.server = MockServer()
self.server.__enter__()
self.addCleanup(self.server.__exit__)
self.state = self.server.state
self.client = BabyTrackerClient(
self.state.email, self.state.password, device_uuid="me", device_name="Test", base_url=self.server.url
)
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.tmp = Path(tmp.name)
class SessionTests(ServerTestCase):
def test_login_sends_the_apps_body_and_returns_account_id(self):
self.assertEqual(self.client.login(), 42)
self.assertEqual(
self.state.login_bodies[0],
{
"AppInfo": {"AccountType": 0, "AppType": 0},
"Device": {"DeviceName": "Test", "DeviceOSInfo": "Python", "DeviceUUID": "me"},
"EmailAddress": "parent@example.com",
"Password": "correct-horse",
},
)
def test_bad_password_raises(self):
self.client.password = "wrong"
with self.assertRaises(AuthenticationError) as caught:
self.client.login()
self.assertEqual(caught.exception.status, 401)
def test_login_307_is_not_followed(self):
self.state.login_status = 307
with self.assertRaises(AccountStateError):
self.client.login()
self.assertNotIn(("POST", "/redirected"), self.state.requests)
def test_expired_session_logs_in_again_and_retries(self):
self.client.login()
self.state.valid_sessions = False
self.assertEqual(self.client.get_devices(), [])
self.assertEqual(len(self.state.login_bodies), 2)
def test_first_request_without_login_works(self):
self.assertEqual(self.client.get_devices(), [])
def test_account_change_on_relogin_raises(self):
self.client.login()
self.state.valid_sessions = False
self.state.account_id = 99
with self.assertRaises(AccountChangedError):
self.client.get_devices()
def test_logout_and_push_token(self):
self.client.login()
self.client.register_push_token("tok")
self.assertEqual(json.loads(self.state.multipart["token"][1]), {"TokenNative": "tok", "TokenAppType": 5})
self.client.logout()
self.assertIn(("DELETE", "/session"), self.state.requests)
class PullTests(ServerTestCase):
def test_pages_until_a_short_page(self):
self.state.logs["other"] = [log_entry(i) for i in range(1, 1203)]
self.client.login()
items = list(self.client.iter_transactions("other"))
self.assertEqual([item.sync_id for item in items], list(range(1, 1203)))
paths = [path for _, path in self.state.requests if "transaction" in path]
self.assertEqual(
paths,
["/account/transaction/other/0", "/account/transaction/other/500", "/account/transaction/other/1000"],
)
def test_exact_page_boundary_fetches_one_empty_page(self):
self.state.logs["other"] = [log_entry(i) for i in range(1, 501)]
self.assertEqual(len(list(self.client.iter_transactions("other"))), 500)
def test_server_ignoring_cursor_does_not_loop_or_repeat(self):
real = self.client.get_transactions
full_page = [Transaction.from_json(log_entry(i)) for i in range(1, 501)]
self.client.get_transactions = lambda device, after=0: full_page
self.addCleanup(setattr, self.client, "get_transactions", real)
self.assertEqual(len(list(self.client.iter_transactions("other"))), 500)
def test_pull_skips_own_device_and_seen_items_and_tracks_cursors(self):
self.state.devices = [
{"DeviceUUID": "me", "LastSyncID": 5},
{"DeviceUUID": "a", "LastSyncID": 3},
{"DeviceUUID": "b", "LastSyncID": 2},
]
self.state.logs = {
"me": [log_entry(5)],
"a": [log_entry(1), log_entry(2), log_entry(3)],
"b": [log_entry(1), log_entry(2)],
}
cursors = {"a": 1, "b": 2}
pulled = [(device, item.sync_id) for device, item in self.client.pull(cursors)]
self.assertEqual(pulled, [("a", 2), ("a", 3)])
self.assertEqual(cursors, {"a": 3, "b": 2})
self.assertEqual(list(self.client.pull(cursors)), [])
def test_opcode_accepts_numbers_as_well_as_strings(self):
self.state.logs["a"] = [log_entry(1, op=2), log_entry(2, op="1")]
items = self.client.get_transactions("a")
self.assertEqual([item.op for item in items], [OpCode.DELETE, OpCode.UPDATE])
self.assertEqual(items[0].object_type, "Bath")
class PushTests(ServerTestCase):
def test_add_uses_gson_wire_format(self):
when = datetime(2026, 11, 4, 8, 30, tzinfo=timezone.utc)
record = diaper(BABY, DiaperStatus.POOPY, DiaperAmount.HEAVY, time=when, note="x")
self.client.add(record)
upload = self.state.uploads[0]
self.assertEqual(set(upload), {"SyncID", "OPCode", "Transaction"})
self.assertEqual(upload["SyncID"], 1)
self.assertEqual(upload["OPCode"], "0")
body = json.loads(base64.b64decode(upload["Transaction"]))
self.assertEqual(body["BCObjectType"], "Diaper")
self.assertEqual(body["status"], "1")
self.assertEqual(body["amount"], "3")
self.assertEqual(body["time"], "2026-11-04 08:30:00 +0000")
self.assertEqual(body["baby"], BABY)
self.assertNotIn("BCObjectType", body["baby"])
self.assertIs(body["pictureLoaded"], True)
self.assertEqual(body["pictureNote"], [])
def test_sync_ids_resume_from_server_then_count(self):
self.state.devices = [{"DeviceUUID": "me", "LastSyncID": 7}, {"DeviceUUID": "x", "LastSyncID": 100}]
self.client.login()
for _ in range(3):
self.client.add(sleep(BABY, 30))
self.assertEqual([u["SyncID"] for u in self.state.uploads], [8, 9, 10])
device_lists = [r for r in self.state.requests if r == ("GET", "/account/device")]
self.assertEqual(len(device_lists), 1)
def test_failed_upload_does_not_consume_a_sync_id(self):
self.client.login()
record = sleep(BABY, 30)
original = self.client.upload_transaction
self.client.upload_transaction = lambda item: (_ for _ in ()).throw(ConflictError(409))
with self.assertRaises(ConflictError):
self.client.add(record)
self.client.upload_transaction = original
self.client.add(record)
self.assertEqual(self.state.uploads[0]["SyncID"], 1)
def test_update_and_delete_send_whole_record_with_new_timestamp(self):
old = datetime(2020, 1, 1, tzinfo=timezone.utc)
record = {**sleep(BABY, 30), "timestamp": old}
self.client.update(record)
self.client.delete(record)
self.assertEqual([u["OPCode"] for u in self.state.uploads], ["1", "2"])
for upload in self.state.uploads:
body = json.loads(base64.b64decode(upload["Transaction"]))
self.assertEqual(body["objectID"], record["objectID"])
self.assertGreater(parse_date(body["timestamp"]), old)
self.assertEqual(record["timestamp"], old)
class FileTests(ServerTestCase):
def _make_seed(self):
db_path = self.tmp / "source.db"
connection = sqlite3.connect(db_path)
connection.execute(
"CREATE TABLE Baby (ID TEXT, Timestamp REAL, Name TEXT, DOB REAL, DueDay REAL, Gender INT, Picture TEXT)"
)
connection.execute('CREATE TABLE MergedTransaction ("DeviceID" TEXT, "SyncID" INTEGER)')
connection.execute("INSERT INTO Baby VALUES ('b1', 1700000000.5, 'Sam', 1600000000, 0, 1, NULL)")
connection.execute("INSERT INTO MergedTransaction VALUES ('a', 9)")
connection.commit()
connection.close()
zip_path = self.tmp / "made.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
archive.write(db_path, "EasyLog.db")
archive.writestr("../../evil.txt", "ignored")
return zip_path.read_bytes()
def test_seed_download_and_read(self):
self.state.seed = self._make_seed()
seed_zip = self.client.download_seed(self.tmp / "seed.zip")
connection = open_seed_db(extract_seed_db(seed_zip, self.tmp / "seed.db"))
self.addCleanup(connection.close)
babies = read_babies(connection)
self.assertEqual(babies[0]["objectID"], "b1")
self.assertIs(babies[0]["gender"], True)
self.assertEqual(babies[0]["dob"], "2020-09-13 12:26:40 +0000")
self.assertNotIn("dueDay", babies[0])
self.assertNotIn("pictureName", babies[0])
self.assertEqual(read_cursors(connection), {"a": 9})
with self.assertRaises(sqlite3.OperationalError):
connection.execute("DELETE FROM Baby")
self.assertFalse((self.tmp.parent / "evil.txt").exists())
self.client.add(diaper(babies[0], DiaperStatus.WET))
body = decode_object(self.state.uploads[0]["Transaction"])
self.assertEqual(body["baby"]["objectID"], "b1")
def test_missing_seed_raises(self):
with self.assertRaises(NotFoundError):
self.client.download_seed(self.tmp / "seed.zip")
def test_seed_zip_without_database_raises(self):
path = self.tmp / "empty.zip"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("other.txt", "x")
with self.assertRaises(ValueError):
extract_seed_db(path, self.tmp / "seed.db")
def test_picture_upload_matches_the_apps_bytes(self):
path = self.tmp / "abc.jpg"
path.write_bytes(b"\xff\xd8jpeg")
self.client.login()
self.client.upload_picture(path)
content_type, body = self.state.multipart["/account/picture"]
self.assertEqual(content_type, "multipart/form-data; boundary=__BABY_TRACKER_BOUNDARY____Photo__")
self.assertEqual(
body,
b"\r\n--__BABY_TRACKER_BOUNDARY____Photo__\r\n"
b'Content-Disposition: form-data; name="picture"; filename="abc.jpg"\r\n'
b"Content-Type: image/jpeg\r\n\r\n"
b"\xff\xd8jpeg"
b"\r\n--__BABY_TRACKER_BOUNDARY____Photo__--\r\n",
)
def test_picture_download_quotes_the_name(self):
self.state.files["abc__thumbnail.jpg"] = b"data"
self.client.login()
path = self.client.download_picture("abc__thumbnail.jpg", self.tmp / "pic.jpg")
self.assertEqual(path.read_bytes(), b"data")
with self.assertRaises(NotFoundError):
self.client.download_picture("../session", self.tmp / "x.jpg")
self.assertIn(("GET", "/account/picture/..%2Fsession"), self.state.requests)
class GroupTests(ServerTestCase):
def setUp(self):
super().setUp()
self.seed = self.tmp / "local.zip"
self.seed.write_bytes(b"zipdata")
self.picture = self.tmp / "p.jpg"
self.picture.write_bytes(b"jpg")
def test_create_group_runs_the_apps_steps_in_order(self):
self.assertEqual(self.client.create_group(self.seed, [self.picture]), 42)
self.assertEqual(
self.state.requests,
[
("POST", "/staging_account"),
("POST", "/staging_account/seed"),
("POST", "/staging_account/picture"),
("PUT", "/staging_account"),
],
)
content_type, body = self.state.multipart["/staging_account/seed"]
self.assertEqual(content_type, "multipart/form-data; boundary=__BABY_TRACKER_BOUNDARY____SEED__")
self.assertIn(b'name="seed"; filename="seed.zip"\r\nContent-Type: application/zip\r\n\r\nzipdata', body)
def test_create_group_for_taken_email_raises(self):
self.client.email = "taken@example.com"
with self.assertRaises(ConflictError):
self.client.create_group(self.seed)
def test_reset_requires_confirmation(self):
with self.assertRaises(ValueError):
self.client.reset_group(self.seed)
self.assertEqual(self.state.requests, [])
def test_reset_with_confirmation(self):
self.assertEqual(self.client.reset_group(self.seed, confirm=True), 42)
self.assertEqual(self.state.requests[0], ("POST", "/account_reset"))
def test_finalizing_without_seed_raises(self):
self.client.login()
with self.assertRaises(MissingSeedError):
self.client._check(self.client._send("PUT", "/staging_account"), 200)
class ModelTests(unittest.TestCase):
def test_date_round_trip_converts_to_gmt(self):
tz = timezone(timedelta(hours=2))
text = format_date(datetime(2026, 11, 4, 10, 0, 5, tzinfo=tz))
self.assertEqual(text, "2026-11-04 08:00:05 +0000")
self.assertEqual(parse_date(text), datetime(2026, 11, 4, 8, 0, 5, tzinfo=timezone.utc))
def test_naive_datetime_is_rejected(self):
with self.assertRaises(ValueError):
format_date(datetime(2026, 1, 1))
def test_parses_the_apps_localized_fallback_dates(self):
expected = datetime(2026, 11, 4, 15, 0, 5, tzinfo=timezone.utc)
for text in (
"2026-11-04 03:00:05 PM +0000",
"2026-11-04 03:00:05 p.m. +0000",
"2026-11-04 03:00:05 nachm. +0000",
"2026-11-04 03:00:05 午後 +0000",
"2026-11-04 03:00:05 下午 +0000",
"2026-11-04 03:00:05 오후 +0000",
"2026-11-04 03:00:05 em +0000",
):
self.assertEqual(parse_date(text), expected, text)
def test_unknown_fields_survive_a_round_trip(self):
record = {"BCObjectType": "FutureType", "newThing": {"a": [1, "é"]}}
self.assertEqual(decode_object(encode_object(record)), record)
def test_record_needs_a_type(self):
with self.assertRaises(ValueError):
encode_object({"objectID": "x"})
def test_enums_become_strings_even_when_nested(self):
record = new_activity("Pump", None, extra=[{"sides": DiaperStatus.MIXED}])
body = decode_object(encode_object(record))
self.assertEqual(body["extra"], [{"sides": "2"}])
self.assertNotIn("baby", body)
def test_builders(self):
body = decode_object(encode_object(formula(BABY, 4, english=True)))
self.assertEqual(body["amount"], {"englishMeasure": True, "value": 4.0})
body = decode_object(encode_object(nursing(BABY, 5, 7, finish_side=NursingFinishSide.RIGHT)))
self.assertEqual((body["leftDuration"], body["rightDuration"], body["finishSide"]), (5, 7, "2"))
body = decode_object(encode_object(growth(BABY, weight=3.5)))
self.assertEqual(body["weight"], {"englishMeasure": False, "value": 3.5})
self.assertNotIn("length", body)
self.assertEqual(decode_object(encode_object(diaper(BABY, DiaperStatus.WET)))["amount"], "5")
def test_builder_time_and_note(self):
when = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
body = decode_object(encode_object(sleep(BABY, 90, time=when, note="nap")))
self.assertEqual((body["time"], body["note"], body["duration"]), ("2026-01-02 03:04:05 +0000", "nap", 90))
if __name__ == "__main__":
unittest.main()
+633
View File
@@ -0,0 +1,633 @@
"""Runs the integration's own code against stand-ins for Home Assistant's APIs.
See tests/ha_stubs/README.md for what this can and cannot show. The client is
always pointed at the local mock server, so these tests never reach the real one.
"""
import base64
import json
import sys
import tempfile
import time
import unittest
from datetime import datetime, timedelta, timezone
from functools import partial
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "tests"))
sys.path.insert(0, str(ROOT / "tests" / "ha_stubs"))
sys.path.insert(0, str(ROOT))
from mock_server import MockServer
from seed_fixture import BABY_ID, build_seed_zip
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
import custom_components.babytracker as integration
from custom_components.babytracker import calendar as calendar_platform
from custom_components.babytracker import config_flow, sensor as sensor_platform
from custom_components.babytracker.babytracker_api import BabyTrackerClient, encode_object
from custom_components.babytracker.const import CONF_DEVICE_UUID, DOMAIN
DEVICE_UUID = "ha-device"
class _ConfigEntries:
def __init__(self):
self.entries = []
def async_entries(self, domain):
return self.entries
async def async_forward_entry_setups(self, entry, platforms):
pass
async def async_unload_platforms(self, entry, platforms):
return True
class IntegrationTestCase(unittest.IsolatedAsyncioTestCase):
units = METRIC_SYSTEM
async def asyncSetUp(self):
self.server = MockServer()
self.server.__enter__()
self.addCleanup(self.server.__exit__)
self.state = self.server.state
self.state.log_uploads = True
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.now = time.time()
self.state.seed = build_seed_zip(Path(self.folder.name), self.now)
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 3}]
client = partial(BabyTrackerClient, base_url=self.server.url)
for target in (integration, config_flow):
patcher = mock.patch.object(target, "BabyTrackerClient", client)
patcher.start()
self.addCleanup(patcher.stop)
dr.REGISTRY.devices.clear()
self.hass = HomeAssistant(self.units)
self.hass.config_entries = _ConfigEntries()
await integration.async_setup(self.hass, {})
def make_entry(self):
return ConfigEntry(
{"email": self.state.email, "password": self.state.password, CONF_DEVICE_UUID: DEVICE_UUID},
str(self.state.account_id),
)
async def load(self):
entry = self.make_entry()
await integration.async_setup_entry(self.hass, entry)
entry.state = ConfigEntryState.LOADED
self.hass.config_entries.entries = [entry]
self.entry = entry
self.coordinator = entry.runtime_data
self.sensors = {}
self.calendars = []
def add_sensors(entities):
for entity in entities:
self.sensors[entity.unique_id if hasattr(entity, "unique_id") else entity._attr_unique_id] = entity
await sensor_platform.async_setup_entry(self.hass, entry, add_sensors)
await calendar_platform.async_setup_entry(self.hass, entry, self.calendars.extend)
return entry
def value(self, key, baby_id=BABY_ID):
return self.sensors[f"{baby_id}_{key}"].native_value
async def call(self, service, data=None, return_response=False):
return await self.hass.services.async_call(DOMAIN, service, data, return_response=return_response)
def last_upload(self):
upload = self.state.uploads[-1]
return upload, json.loads(base64.b64decode(upload["Transaction"]))
def remote_entry(self, sync_id, record):
return {"SyncID": sync_id, "OPCode": "0", "Transaction": encode_object(record)}
class SetupTests(IntegrationTestCase):
async def test_loads_seed_into_sensors(self):
await self.load()
last = self.value("last_diaper")
self.assertEqual(last, datetime.fromtimestamp(self.now - 60, timezone.utc))
attributes = self.sensors[f"{BABY_ID}_last_diaper"].extra_state_attributes
self.assertEqual((attributes["status"], attributes["amount"], attributes["note"]), ("poopy", "heavy", "messy"))
self.assertAlmostEqual(self.value("temperature"), 37.0, places=3)
self.assertAlmostEqual(self.value("weight"), 6.0, places=3)
self.assertEqual(self.value("length"), 60)
self.assertIn(("POST", "/session"), self.state.requests)
async def test_entities_have_unique_ids_and_a_device_per_baby(self):
await self.load()
self.assertEqual(len(self.sensors), len(sensor_platform.SENSORS) + 1)
sensor = self.sensors[f"{BABY_ID}_weight"]
self.assertEqual(sensor._attr_device_info["identifiers"], {(DOMAIN, BABY_ID)})
self.assertEqual(sensor._attr_device_info["name"], "Sam")
self.assertEqual(self.calendars[0]._attr_translation_key, "log")
async def test_bad_password_starts_reauth(self):
entry = self.make_entry()
self.state.password = "changed"
with self.assertRaises(ConfigEntryAuthFailed):
await integration.async_setup_entry(self.hass, entry)
async def test_server_down_retries_setup(self):
self.state.fail_with = 500
with self.assertRaises(ConfigEntryNotReady):
await integration.async_setup_entry(self.hass, self.make_entry())
async def test_account_without_seed_still_loads(self):
self.state.seed = None
self.state.devices = []
await self.load()
self.assertEqual(self.coordinator.data.babies, {})
async def test_seed_files_are_not_left_behind(self):
import glob
import tempfile as tf
await self.load()
self.assertEqual(glob.glob(f"{tf.gettempdir()}/babytracker*"), [])
class SyncTests(IntegrationTestCase):
async def test_replays_entries_newer_than_the_seed_and_skips_older(self):
record = {
"BCObjectType": "Sleep", "objectID": "s-remote", "baby": {"objectID": BABY_ID},
"time": "2030-01-01 00:00:00 +0000", "duration": 10,
}
self.state.logs["phone"] = [
self.remote_entry(3, {**record, "objectID": "already-in-seed"}),
self.remote_entry(4, record),
]
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}]
await self.load()
ids = {entry.id for entry in self.coordinator.data.entries(BABY_ID)}
self.assertIn("s-remote", ids)
self.assertNotIn("already-in-seed", ids)
async def test_refresh_fetches_new_entries_only(self):
await self.load()
self.state.requests.clear()
self.state.logs["phone"] = [
self.remote_entry(4, {
"BCObjectType": "Diaper", "objectID": "d-remote", "baby": {"objectID": BABY_ID},
"time": "2030-01-01 00:00:00 +0000", "status": "0", "amount": "2",
})
]
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}]
await self.coordinator.async_refresh()
self.assertEqual(self.coordinator.data.latest(BABY_ID).id, "d-remote")
self.assertIn(("GET", "/account/transaction/phone/3"), self.state.requests)
self.assertNotIn(("GET", "/account/seed"), self.state.requests)
self.state.requests.clear()
await self.coordinator.async_refresh()
self.assertFalse([r for r in self.state.requests if "transaction" in r[1]])
async def test_failed_refresh_loses_nothing_and_recovers(self):
await self.load()
self.state.logs["phone"] = [self.remote_entry(4, {
"BCObjectType": "Bath", "objectID": "b-remote", "baby": {"objectID": BABY_ID},
"time": "2030-01-01 00:00:00 +0000",
})]
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}]
self.state.fail_with = 503
await self.coordinator.async_refresh()
self.assertFalse(self.coordinator.last_update_success)
self.assertFalse(self.sensors[f"{BABY_ID}_weight"].available)
self.state.fail_with = None
await self.coordinator.async_refresh()
self.assertTrue(self.coordinator.last_update_success)
self.assertEqual(self.coordinator.data.latest(BABY_ID).id, "b-remote")
async def test_unreadable_entry_is_skipped(self):
await self.load()
self.state.logs["phone"] = [
{"SyncID": 4, "OPCode": "0", "Transaction": "not base64 json!"},
self.remote_entry(5, {"BCObjectType": "Bath", "objectID": "good", "baby": {"objectID": BABY_ID},
"time": "2030-01-01 00:00:00 +0000"}),
self.remote_entry(6, {"BCObjectType": "Bath", "objectID": "bad-date", "baby": {"objectID": BABY_ID},
"time": "whenever"}),
]
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 6}]
with self.assertLogs("custom_components.babytracker.coordinator", "WARNING"):
await self.coordinator.async_refresh()
self.assertTrue(self.coordinator.last_update_success)
self.assertEqual(self.coordinator.data.latest(BABY_ID).id, "good")
async def test_new_baby_from_the_log_gets_entities(self):
await self.load()
self.state.logs["phone"] = [self.remote_entry(4, {"BCObjectType": "Baby", "objectID": "b2", "name": "Alex"})]
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}]
await self.coordinator.async_refresh()
self.assertIn("b2_last_diaper", self.sensors)
self.assertEqual(len(self.calendars), 2)
async def test_deleted_baby_becomes_unavailable(self):
await self.load()
self.state.logs["phone"] = [{
"SyncID": 4, "OPCode": "2", "Transaction": encode_object({"BCObjectType": "Baby", "objectID": BABY_ID})
}]
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}]
await self.coordinator.async_refresh()
self.assertFalse(self.sensors[f"{BABY_ID}_weight"].available)
async def test_expired_password_on_refresh_starts_reauth(self):
await self.load()
self.state.valid_sessions = False
self.state.password = "changed"
with self.assertRaises(ConfigEntryAuthFailed):
await self.coordinator.async_refresh()
async def test_different_account_is_refused(self):
self.state.account_id = 777
with self.assertRaises(ConfigEntryAuthFailed):
await integration.async_setup_entry(self.hass, ConfigEntry(
{"email": self.state.email, "password": self.state.password, CONF_DEVICE_UUID: DEVICE_UUID}, "42"
))
async def test_restart_keeps_what_this_device_uploaded(self):
await self.load()
await self.call("log_bath", {"note": "first"})
await self.call("log_bath", {"note": "second"})
self.assertEqual([u["SyncID"] for u in self.state.uploads], [1, 2])
await self.load() # A new client and replica, as after a restart.
notes = [e.note for e in self.coordinator.data.entries(BABY_ID, {"Bath"})]
self.assertCountEqual(notes, ["first", "second"])
await self.call("log_bath", {"note": "third"})
self.assertEqual(self.state.uploads[-1]["SyncID"], 3)
class ServiceTests(IntegrationTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
await self.load()
async def test_every_service_is_registered_with_optional_response(self):
names = {name for _, name in self.hass.services.registered}
self.assertEqual(names, {
"log_diaper", "log_sleep", "log_bottle", "log_nursing", "log_temperature",
"log_growth", "log_bath", "log_joy", "log_journal",
})
async def test_diaper_uploads_and_updates_sensors_at_once(self):
before = self.value("diapers_today")
response = await self.call("log_diaper", {"status": "mixed", "amount": "light", "note": "n"}, return_response=True)
upload, body = self.last_upload()
self.assertEqual(upload["OPCode"], "0")
self.assertEqual((body["BCObjectType"], body["status"], body["amount"], body["note"]), ("Diaper", "2", "1", "n"))
self.assertEqual(body["baby"]["objectID"], BABY_ID)
self.assertEqual(body["baby"]["name"], "Sam")
self.assertNotIn("BCObjectType", body["baby"])
self.assertEqual(response, {"id": body["objectID"]})
self.assertEqual(self.value("diapers_today"), before + 1)
self.assertEqual(self.sensors[f"{BABY_ID}_last_diaper"].extra_state_attributes["status"], "mixed")
async def test_no_response_unless_asked(self):
self.assertIsNone(await self.call("log_diaper", {"status": "wet"}))
async def test_diaper_without_amount_is_not_counted_dry(self):
await self.call("log_diaper", {"status": "wet"})
_, body = self.last_upload()
self.assertEqual(body["amount"], "5")
self.assertEqual(self.sensors[f"{BABY_ID}_last_diaper"].extra_state_attributes["status"], "wet")
async def test_sleep_defaults_to_having_just_finished(self):
before = self.value("sleep_today")
await self.call("log_sleep", {"duration": 30})
_, body = self.last_upload()
started = datetime.strptime(body["time"], "%Y-%m-%d %H:%M:%S %z")
self.assertAlmostEqual((datetime.now(timezone.utc) - started).total_seconds(), 1800, delta=5)
self.assertEqual(body["duration"], 30)
if started.date() == datetime.now(timezone.utc).date():
self.assertEqual(self.value("sleep_today"), before + 30)
async def test_explicit_time_is_read_as_local_and_sent_as_gmt(self):
await self.call("log_bath", {"time": "2026-03-01T10:00:00"})
_, body = self.last_upload()
self.assertEqual(body["time"], "2026-03-01 10:00:00 +0000")
async def test_bottle_in_metric_by_default(self):
before = self.value("bottle_volume_today")
await self.call("log_bottle", {"amount": 120})
_, body = self.last_upload()
self.assertEqual(body["BCObjectType"], "Formula")
self.assertEqual(body["amount"], {"englishMeasure": False, "value": 120.0})
self.assertEqual(self.value("bottle_volume_today"), before + 120)
self.assertEqual(self.sensors[f"{BABY_ID}_last_feeding"].extra_state_attributes["type"], "Formula")
async def test_pumped_bottle_with_explicit_ounces(self):
await self.call("log_bottle", {"amount": 4, "kind": "pumped", "unit": "oz"})
_, body = self.last_upload()
self.assertEqual(body["BCObjectType"], "Pumped")
self.assertEqual(body["amount"], {"englishMeasure": True, "value": 4.0})
async def test_nursing(self):
await self.call("log_nursing", {"left_minutes": 5, "right_minutes": 7, "finish_side": "right"})
_, body = self.last_upload()
self.assertEqual((body["leftDuration"], body["rightDuration"], body["bothDuration"], body["finishSide"]), (5, 7, 0, "2"))
started = datetime.strptime(body["time"], "%Y-%m-%d %H:%M:%S %z")
self.assertAlmostEqual((datetime.now(timezone.utc) - started).total_seconds(), 720, delta=5)
async def test_temperature_and_growth(self):
await self.call("log_temperature", {"value": 38.5})
self.assertEqual(self.value("temperature"), 38.5)
await self.call("log_growth", {"weight": 7.1})
_, body = self.last_upload()
self.assertEqual(body["weight"], {"englishMeasure": False, "value": 7.1})
self.assertNotIn("length", body)
self.assertEqual(self.value("weight"), 7.1)
self.assertEqual(self.value("length"), 60)
async def test_journal_keeps_its_text(self):
await self.call("log_journal", {"note": "First laugh"})
_, body = self.last_upload()
self.assertEqual((body["BCObjectType"], body["note"]), ("Journal", "First laugh"))
async def test_invalid_input_is_rejected_before_upload(self):
for service, data in (
("log_diaper", {}),
("log_diaper", {"status": "sparkly"}),
("log_diaper", {"status": "wet", "amount": "noset"}),
("log_sleep", {}),
("log_sleep", {"duration": -1}),
("log_bottle", {"amount": -5}),
("log_nursing", {}),
("log_nursing", {"left_minutes": 0}),
("log_growth", {}),
("log_journal", {}),
("log_journal", {"note": ""}),
("log_bath", {"unknown": 1}),
):
with self.subTest(service=service, data=data), self.assertRaises(Exception):
await self.call(service, data)
self.assertEqual(self.state.uploads, [])
async def test_baby_by_device_id_and_by_name(self):
dr.REGISTRY.devices["device-1"] = dr.DeviceEntry({(DOMAIN, BABY_ID)})
await self.call("log_bath", {"baby": "device-1"})
await self.call("log_bath", {"baby": "sam"})
self.assertEqual(len(self.state.uploads), 2)
async def test_unknown_baby_is_an_error(self):
with self.assertRaises(ServiceValidationError) as caught:
await self.call("log_bath", {"baby": "Nobody"})
self.assertEqual(caught.exception.translation_key, "baby_not_found")
async def test_baby_is_required_with_two_babies(self):
self.state.logs["phone"] = [self.remote_entry(4, {"BCObjectType": "Baby", "objectID": "b2", "name": "Alex"})]
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}]
await self.coordinator.async_refresh()
with self.assertRaises(ServiceValidationError) as caught:
await self.call("log_bath")
self.assertEqual(caught.exception.translation_key, "baby_required")
await self.call("log_bath", {"baby": "Alex"})
self.assertEqual(self.last_upload()[1]["baby"]["objectID"], "b2")
async def test_failed_upload_raises_and_changes_nothing(self):
before = self.value("diapers_today")
self.state.fail_with = 500
with self.assertRaises(HomeAssistantError) as caught:
await self.call("log_diaper", {"status": "wet"})
self.assertEqual(caught.exception.translation_key, "upload_failed")
self.assertEqual(self.value("diapers_today"), before)
self.state.fail_with = None
await self.call("log_diaper", {"status": "wet"})
self.assertEqual(self.state.uploads[-1]["SyncID"], 1)
async def test_uploads_keep_order_when_called_together(self):
import asyncio
await asyncio.gather(*(self.call("log_bath", {"note": str(i)}) for i in range(5)))
self.assertEqual([u["SyncID"] for u in self.state.uploads], [1, 2, 3, 4, 5])
async def test_nothing_loaded_means_no_baby(self):
self.entry.state = ConfigEntryState.NOT_LOADED
with self.assertRaises(ServiceValidationError):
await self.call("log_bath")
class UsCustomaryTests(IntegrationTestCase):
units = US_CUSTOMARY_SYSTEM
async def test_units_default_to_home_assistants_system(self):
await self.load()
await self.call("log_bottle", {"amount": 4})
self.assertIs(self.last_upload()[1]["amount"]["englishMeasure"], True)
await self.call("log_temperature", {"value": 100.4})
self.assertAlmostEqual(self.value("temperature"), 38.0, places=3)
await self.call("log_growth", {"weight": 11, "length": 20})
body = self.last_upload()[1]
self.assertIs(body["weight"]["englishMeasure"], True)
self.assertAlmostEqual(self.value("length"), 50.8, places=3)
await self.call("log_bottle", {"amount": 100, "unit": "ml"})
self.assertIs(self.last_upload()[1]["amount"]["englishMeasure"], False)
class DisplayTests(IntegrationTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
await self.load()
async def test_today_counts_use_the_local_day(self):
from homeassistant.util import dt as dt_util
now = datetime.now(timezone.utc)
start = now - timedelta(hours=1)
with mock.patch.object(dt_util, "start_of_local_day", return_value=start):
self.assertEqual(self.value("diapers_today"), 1)
with mock.patch.object(dt_util, "start_of_local_day", return_value=now - timedelta(hours=3)):
self.assertEqual(self.value("diapers_today"), 2)
self.assertEqual(self.value("feedings_today"), 2)
async def test_recent_activity_is_bounded_and_unrecorded(self):
sensor = self.sensors[f"{BABY_ID}_recent_activity"]
self.assertEqual(sensor._unrecorded_attributes, frozenset({"entries"}))
for i in range(25):
await self.call("log_bath", {"note": str(i)})
entries = sensor.extra_state_attributes["entries"]
self.assertEqual(len(entries), 20)
self.assertEqual(set(entries[0]), {"id", "type", "summary", "time", "note"})
json.dumps(entries)
self.assertEqual(sensor.native_value, self.coordinator.data.latest(BABY_ID).time)
async def test_calendar_window_and_durations(self):
calendar = self.calendars[0]
now = datetime.now(timezone.utc)
events = await calendar.async_get_events(self.hass, now - timedelta(hours=4), now)
by_uid = {event.uid: event for event in events}
self.assertIn("s-1", by_uid)
self.assertEqual(by_uid["s-1"].end - by_uid["s-1"].start, timedelta(minutes=45))
self.assertEqual(by_uid["d-new"].end - by_uid["d-new"].start, timedelta(minutes=1))
self.assertEqual(by_uid["d-new"].description, "messy")
self.assertNotIn("n-1", by_uid)
self.assertEqual(calendar.event.uid, "d-new")
async def test_calendar_includes_sleep_overlapping_the_window(self):
calendar = self.calendars[0]
start = datetime.fromtimestamp(self.now - 3 * 3600 + 600, timezone.utc)
events = await calendar.async_get_events(self.hass, start, start + timedelta(minutes=5))
self.assertEqual([event.uid for event in events], ["s-1"])
async def test_no_entries(self):
self.state.seed = None
self.state.devices = []
self.state.logs = {"phone": [self.remote_entry(4, {"BCObjectType": "Baby", "objectID": "b3", "name": "Kim"})]}
self.state.devices = [{"DeviceUUID": "phone", "LastSyncID": 4}]
await self.load()
self.assertIsNone(self.value("last_diaper", "b3"))
self.assertEqual(self.value("diapers_today", "b3"), 0)
self.assertEqual(self.value("sleep_today", "b3"), 0)
self.assertIsNone(self.value("weight", "b3"))
self.assertEqual(self.sensors["b3_last_diaper"].extra_state_attributes, {})
calendar = next(c for c in self.calendars if c.baby_id == "b3")
self.assertIsNone(calendar.event)
class ConfigFlowTests(IntegrationTestCase):
def flow(self, existing=()):
flow = config_flow.BabyTrackerConfigFlow()
flow.hass = self.hass
flow.existing_unique_ids = set(existing)
return flow
async def test_shows_form_first(self):
result = await self.flow().async_step_user()
self.assertEqual((result["type"], result["step_id"]), ("form", "user"))
async def test_creates_entry_with_a_lasting_device_id(self):
result = await self.flow().async_step_user({"email": self.state.email, "password": self.state.password})
self.assertEqual(result["type"], "create_entry")
self.assertEqual(result["title"], self.state.email)
self.assertEqual(result["data"][CONF_DEVICE_UUID], self.state.login_bodies[0]["Device"]["DeviceUUID"])
self.assertEqual(self.state.login_bodies[0]["Device"]["DeviceName"], "Home Assistant")
async def test_wrong_password(self):
result = await self.flow().async_step_user({"email": self.state.email, "password": "nope"})
self.assertEqual(result["errors"], {"base": "invalid_auth"})
async def test_server_error(self):
self.state.login_status = 500
result = await self.flow().async_step_user({"email": self.state.email, "password": self.state.password})
self.assertEqual(result["errors"], {"base": "cannot_connect"})
async def test_307(self):
self.state.login_status = 307
result = await self.flow().async_step_user({"email": self.state.email, "password": self.state.password})
self.assertEqual(result["errors"], {"base": "account_state"})
async def test_unreachable_server(self):
client = partial(BabyTrackerClient, base_url="http://127.0.0.1:9", timeout=2)
with mock.patch.object(config_flow, "BabyTrackerClient", client):
result = await self.flow().async_step_user({"email": "a@b.c", "password": "x"})
self.assertEqual(result["errors"], {"base": "cannot_connect"})
async def test_same_account_twice_aborts(self):
with self.assertRaises(AbortFlow) as caught:
await self.flow(existing={"42"}).async_step_user({"email": self.state.email, "password": self.state.password})
self.assertEqual(caught.exception.reason, "already_configured")
async def test_reauth_updates_password_and_keeps_device_id(self):
entry = self.make_entry()
flow = self.flow()
flow.reauth_entry = entry
result = await flow.async_step_reauth(entry.data)
self.assertEqual(result["step_id"], "reauth_confirm")
self.assertEqual(result["description_placeholders"], {"email": self.state.email})
self.state.password = "new-password"
result = await flow.async_step_reauth_confirm({"password": "wrong"})
self.assertEqual(result["errors"], {"base": "invalid_auth"})
result = await flow.async_step_reauth_confirm({"password": "new-password"})
self.assertEqual(result["reason"], "reauth_successful")
self.assertEqual(entry.data["password"], "new-password")
self.assertEqual(entry.data[CONF_DEVICE_UUID], DEVICE_UUID)
self.assertEqual(self.state.login_bodies[-1]["Device"]["DeviceUUID"], DEVICE_UUID)
async def test_reauth_with_another_account_aborts(self):
entry = ConfigEntry({"email": self.state.email, "password": "x", CONF_DEVICE_UUID: DEVICE_UUID}, "999")
flow = self.flow()
flow.reauth_entry = entry
with self.assertRaises(AbortFlow) as caught:
await flow.async_step_reauth_confirm({"password": self.state.password})
self.assertEqual(caught.exception.reason, "wrong_account")
class PackagingTests(unittest.TestCase):
"""Checks that the integration's data files agree with its code."""
BASE = ROOT / "custom_components" / "babytracker"
@classmethod
def setUpClass(cls):
import yaml
cls.services = yaml.safe_load((cls.BASE / "services.yaml").read_text())
cls.translations = json.loads((cls.BASE / "translations" / "en.json").read_text())
cls.icons = json.loads((cls.BASE / "icons.json").read_text())
cls.manifest = json.loads((cls.BASE / "manifest.json").read_text())
from custom_components.babytracker import services
cls.schemas = {}
for name, (_, schema) in services._SERVICES.items():
schema = schema.validators[0] if hasattr(schema, "validators") else schema
cls.schemas[name] = {marker.key for marker in schema.schema}
def test_services_yaml_matches_the_schemas(self):
declared = {name: set(body["fields"]) for name, body in self.services.items() if not name.startswith(".")}
self.assertEqual(declared, self.schemas)
def test_every_service_field_is_translated_and_has_an_icon(self):
for name, fields in self.schemas.items():
translated = self.translations["services"][name]
self.assertEqual(set(translated["fields"]), fields, name)
self.assertIn(name, self.icons["services"])
def test_every_sensor_is_translated(self):
keys = {description.key for description in sensor_platform.SENSORS} | {"recent_activity"}
self.assertEqual(set(self.translations["entity"]["sensor"]), keys)
self.assertLessEqual(set(self.icons["entity"]["sensor"]), keys)
def test_every_error_is_translated(self):
source = (self.BASE / "config_flow.py").read_text()
for key in self.translations["config"]["error"]:
self.assertIn(f'"{key}"', source)
import re
services_source = (self.BASE / "services.py").read_text()
keys = set(re.findall(r'translation_key="(\w+)"', services_source))
self.assertEqual(keys, set(self.translations["exceptions"]))
def test_manifest_has_what_hacs_requires(self):
for key in ("domain", "name", "codeowners", "documentation", "issue_tracker", "version", "config_flow"):
self.assertIn(key, self.manifest)
self.assertEqual(self.manifest["domain"], DOMAIN)
hacs = json.loads((ROOT / "hacs.json").read_text())
self.assertIn("name", hacs)
def test_library_needs_only_what_the_manifest_declares(self):
import re
for path in (self.BASE / "babytracker_api").glob("*.py"):
for module in re.findall(r"^(?:from|import) (\w+)", path.read_text(), re.M):
self.assertNotIn(module, ("homeassistant", "custom_components"), path.name)
self.assertEqual(self.manifest["requirements"], ["requests>=2.25"])
if __name__ == "__main__":
unittest.main()
+197
View File
@@ -0,0 +1,197 @@
import sys
import tempfile
import time
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import load_library # noqa: F401
from seed_fixture import BABY_ID, build_seed_db
from babytracker_api import (
DiaperStatus,
FEED_KINDS,
OpCode,
Replica,
decode_object,
diaper,
encode_object,
format_date,
formula,
open_seed_db,
read_cursors,
sleep,
)
NOW = time.time()
BABY = {"objectID": BABY_ID, "name": "Sam", "newFlage": False}
def wire(record):
"""Round-trip a record the way it travels in a transaction."""
return decode_object(encode_object(record))
def stamped(record, seconds):
return {**record, "timestamp": format_date(datetime.fromtimestamp(NOW + seconds, timezone.utc))}
class SeedTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.folder = tempfile.TemporaryDirectory()
cls.connection = open_seed_db(build_seed_db(Path(cls.folder.name) / "seed.db", NOW))
@classmethod
def tearDownClass(cls):
cls.connection.close()
cls.folder.cleanup()
def setUp(self):
self.replica = Replica.from_seed(self.connection)
def details(self, entry_id):
return next(e for e in self.replica.entries(BABY_ID) if e.id == entry_id).details
def test_reads_babies_and_cursors(self):
self.assertEqual(self.replica.babies[BABY_ID].name, "Sam")
self.assertIs(self.replica.babies[BABY_ID].record["gender"], True)
self.assertEqual(read_cursors(self.connection), {"phone": 3})
def test_entries_are_newest_first(self):
times = [entry.time for entry in self.replica.entries(BABY_ID)]
self.assertEqual(times, sorted(times, reverse=True))
self.assertEqual(self.replica.latest(BABY_ID).id, "d-new")
def test_diaper_names_and_the_apps_dry_rule(self):
self.assertEqual(self.details("d-new"), {"status": "poopy", "amount": "heavy"})
self.assertEqual(self.details("d-dry"), {"status": "dry", "amount": None})
def test_units_become_metric(self):
self.assertAlmostEqual(self.details("f-oz")["volume_ml"], 118.294, places=2)
self.assertEqual(self.details("f-ml")["volume_ml"], 100)
self.assertAlmostEqual(self.details("t-1")["celsius"], 37.0, places=3)
self.assertAlmostEqual(self.details("g-new")["weight_kg"], 6.0, places=3)
def test_unset_growth_columns_are_none(self):
self.assertIsNone(self.details("g-new")["length_cm"])
self.assertEqual(self.details("g-old")["length_cm"], 60)
def test_selection_names_are_joined(self):
self.assertEqual(self.details("m-1")["name"], "Vitamin D")
def test_nursing_total_gives_the_end_time(self):
entry = next(e for e in self.replica.entries(BABY_ID) if e.id == "n-1")
self.assertEqual(entry.details["duration"], 12)
self.assertEqual(entry.end - entry.time, timedelta(minutes=12))
self.assertEqual(entry.details["finish_side"], "right")
self.assertEqual(entry.summary, "Nursing: left 5 min, right 7 min")
def test_summaries(self):
by_id = {entry.id: entry.summary for entry in self.replica.entries(BABY_ID)}
self.assertEqual(by_id["d-new"], "Diaper: poopy, heavy")
self.assertEqual(by_id["f-ml"], "Formula: 100 ml")
self.assertEqual(by_id["s-1"], "Sleep: 45 min")
self.assertEqual(by_id["m-1"], "Medication: Vitamin D")
self.assertEqual(by_id["t-1"], "Temperature: 37.0 °C")
self.assertEqual(by_id["k-1"], "Sick")
def test_pump_belongs_to_no_baby(self):
self.assertEqual([entry.id for entry in self.replica.entries(None)], ["p-1"])
def test_since_and_kind_filters(self):
start = datetime.fromtimestamp(NOW - 4 * 3600, timezone.utc)
self.assertEqual([e.id for e in self.replica.since(BABY_ID, start, {"Diaper"})], ["d-new", "d-dry"])
self.assertEqual({e.kind for e in self.replica.entries(BABY_ID, FEED_KINDS)}, {"Formula", "Nursing"})
def test_missing_tables_are_skipped(self):
path = Path(self.folder.name) / "tiny.db"
import sqlite3
connection = sqlite3.connect(path)
connection.execute('CREATE TABLE "Baby" (ID, Timestamp, Name, DOB, DueDay, Gender, Picture)')
connection.commit()
connection.close()
tiny = open_seed_db(path)
self.addCleanup(tiny.close)
self.assertEqual(Replica.from_seed(tiny).babies, {})
class LogTests(unittest.TestCase):
def setUp(self):
self.replica = Replica()
self.replica.apply(OpCode.INSERT, {"BCObjectType": "Baby", **BABY})
def test_insert_update_delete(self):
record = sleep(BABY, 30)
self.replica.apply(OpCode.INSERT, stamped(wire(record), 0))
self.assertEqual(self.replica.latest(BABY_ID).details["duration"], 30)
self.replica.apply(OpCode.UPDATE, stamped(wire({**record, "duration": 40}), 10))
self.assertEqual(self.replica.latest(BABY_ID).details["duration"], 40)
self.replica.apply(OpCode.DELETE, stamped(wire(record), 20))
self.assertIsNone(self.replica.latest(BABY_ID))
def test_older_update_does_not_replace_newer(self):
record = sleep(BABY, 30)
self.replica.apply(OpCode.UPDATE, stamped(wire(record), 10))
self.replica.apply(OpCode.UPDATE, stamped(wire({**record, "duration": 99}), 5))
self.assertEqual(self.replica.latest(BABY_ID).details["duration"], 30)
def test_older_update_does_not_undo_delete(self):
record = sleep(BABY, 30)
self.replica.apply(OpCode.DELETE, stamped(wire(record), 10))
self.replica.apply(OpCode.UPDATE, stamped(wire(record), 5))
self.assertIsNone(self.replica.latest(BABY_ID))
def test_enums_read_from_the_wire(self):
self.replica.apply(OpCode.INSERT, wire(diaper(BABY, DiaperStatus.MIXED)))
self.assertEqual(self.replica.latest(BABY_ID).details, {"status": "mixed", "amount": None})
def test_english_bottle(self):
self.replica.apply(OpCode.INSERT, wire(formula(BABY, 1, english=True)))
self.assertAlmostEqual(self.replica.latest(BABY_ID).details["volume_ml"], 29.5735)
def test_localized_date_is_understood(self):
record = {**wire(sleep(BABY, 1)), "time": "2026-01-02 03:00:05 nachm. +0000"}
self.replica.apply(OpCode.INSERT, record)
self.assertEqual(self.replica.latest(BABY_ID).time.hour, 15)
def test_unknown_type_with_a_time_is_kept(self):
record = {"BCObjectType": "FutureThing", "objectID": "x", "baby": BABY, "time": "2026-01-02 03:00:05 +0000"}
self.replica.apply(OpCode.INSERT, record)
self.assertEqual(self.replica.latest(BABY_ID).summary, "FutureThing")
def test_selection_and_picture_records_are_ignored(self):
self.replica.apply(OpCode.INSERT, {"BCObjectType": "OtherFeedSelection", "objectID": "s", "name": "Fruit"})
self.replica.apply(OpCode.INSERT, {"BCObjectType": "Picture", "objectID": "p", "fileName": "p"})
self.assertIsNone(self.replica.latest(BABY_ID))
def test_baby_added_renamed_and_deleted(self):
other = {"BCObjectType": "Baby", "objectID": "b2", "name": "Alex"}
self.replica.apply(OpCode.INSERT, other)
self.replica.apply(OpCode.UPDATE, {**other, "name": "Alexa"})
self.assertEqual(self.replica.babies["b2"].name, "Alexa")
self.replica.apply(OpCode.DELETE, other)
self.assertNotIn("b2", self.replica.babies)
def test_conflict_moves_entries_to_the_winner(self):
loser = {"BCObjectType": "Baby", "objectID": "b2", "name": "Sam"}
self.replica.apply(OpCode.INSERT, loser)
self.replica.apply(OpCode.INSERT, wire(sleep({"objectID": "b2"}, 15)))
winner = {"BCObjectType": "Baby", **BABY}
self.replica.apply(OpCode.CONFLICT, {"BCObjectType": "ConflictRecord", "objectID": "c", "winner": winner, "loser": loser})
self.assertNotIn("b2", self.replica.babies)
self.assertIn(BABY_ID, self.replica.babies)
self.assertEqual(self.replica.latest(BABY_ID).details["duration"], 15)
self.assertIsNone(self.replica.latest("b2"))
def test_selection_conflict_changes_nothing(self):
selection = {"BCObjectType": "OtherFeedSelection", "objectID": "s"}
self.replica.apply(OpCode.CONFLICT, {"BCObjectType": "ConflictRecord", "winner": selection, "loser": selection})
self.assertEqual(list(self.replica.babies), [BABY_ID])
if __name__ == "__main__":
unittest.main()