utils/crdt-sync/crdt_sync/tests/test_device.py
Krzysztof kuhy Rudnicki 6eef99d661
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
Add DeviceIdentity and a legacy device id for the uuid migration
Groundwork for moving diet-guard, screen-locker and wake-alarm off fixed
role ids ('pc'/'phone'/'desktop') onto per-install uuids, the scheme todo
and home_inventory already use. A role constant collides the moment a
second machine takes the same role, and a reinstall silently inherits the
previous install's CRDT identity.

The migration cannot rewrite history: the device id is baked into every HLC
stamp already written, is the record path segment and the revision key. So
a device that has just switched ids must still recognise its former id as
its own -- otherwise skip-own-writes stops matching the old path, and the
device pulls back and re-merges its own pre-migration log as though a peer
had written it. Idempotent under LWW, but pure wasted transfer on every
tick.

Both halves therefore take an optional legacy id and test set membership
(own_ids / ownIds) rather than equality against a single id:

- crdt-sync (Python) 0.5.1 -> 0.6.0: new crdt_sync._device with
  DeviceIdentity + load_device_identity (generate-or-load, persisted); the
  mkdir is deliberately unguarded, since handing back an unpersisted id
  would mint a fresh uuid every restart and strand a device dir each time.
  sync_log gains legacy_device_id.
- crdt_sync_dart 0.6.0 -> 0.7.0: DeviceIdentity value type; syncLog gains
  legacyDeviceId. Persistence stays app-side, where the existing uuid apps
  already keep it in SharedPreferences under 'crdt.nodeId'.

Both changes are backward compatible: the parameter defaults to null, which
reproduces the previous single-id behaviour exactly.

Each half has a positive test and a negative control proving the old path
*is* pulled when no legacy id is declared -- so the skip is verified to be
the parameter's doing, not an artifact of the fixture.

Python: 239 tests, 100% branch coverage. Dart: 248 tests, analyzer clean,
device.dart 11/11 and sync.dart 57/57 lines covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 16:54:33 +02:00

114 lines
3.8 KiB
Python

"""Tests for this device's persisted sync identity."""
from __future__ import annotations
from typing import TYPE_CHECKING
import uuid
import pytest
from crdt_sync import DeviceIdentity, load_device_identity
if TYPE_CHECKING:
from pathlib import Path
def test_mints_a_uuid_on_first_call(tmp_path: Path) -> None:
"""A device with no id file gets a fresh uuid4, persisted."""
path = tmp_path / "device_id"
identity = load_device_identity(path)
assert uuid.UUID(identity.device_id).version == 4
assert path.read_text(encoding="utf-8").strip() == identity.device_id
def test_returns_the_same_id_on_every_later_call(tmp_path: Path) -> None:
"""The id is stable across restarts -- that is the whole point."""
path = tmp_path / "device_id"
first = load_device_identity(path)
second = load_device_identity(path)
assert first.device_id == second.device_id
def test_creates_missing_parent_directories(tmp_path: Path) -> None:
"""First run on a clean machine has no state dir yet."""
path = tmp_path / "nested" / "deeper" / "device_id"
identity = load_device_identity(path)
assert path.read_text(encoding="utf-8").strip() == identity.device_id
def test_treats_a_blank_file_as_absent(tmp_path: Path) -> None:
"""An empty/whitespace file (interrupted first write) re-mints."""
path = tmp_path / "device_id"
path.write_text(" \n", encoding="utf-8")
identity = load_device_identity(path)
assert uuid.UUID(identity.device_id).version == 4
def test_reads_an_id_that_is_not_a_uuid(tmp_path: Path) -> None:
"""Whatever is on disk wins; the file is the source of truth."""
path = tmp_path / "device_id"
path.write_text("hand-edited-id\n", encoding="utf-8")
assert load_device_identity(path).device_id == "hand-edited-id"
def test_a_file_blocking_the_state_dir_surfaces_as_an_error(tmp_path: Path) -> None:
"""A file where the state directory belongs is a real misconfiguration.
The read is tolerant (it reads as "no id yet"), but the subsequent mkdir
must not be: silently continuing would hand back an id that was never
persisted, so every restart would mint a new one and strand a device
directory in the namespace each time.
"""
blocker = tmp_path / "blocker"
blocker.write_text("not a directory\n", encoding="utf-8")
with pytest.raises((NotADirectoryError, FileExistsError)):
load_device_identity(tmp_path / "blocker" / "device_id")
def test_legacy_id_is_carried_onto_the_identity(tmp_path: Path) -> None:
"""The pre-migration role constant travels with the new uuid."""
identity = load_device_identity(tmp_path / "device_id", legacy_id="pc")
assert identity.legacy_id == "pc"
def test_legacy_id_is_carried_when_loading_an_existing_id(tmp_path: Path) -> None:
"""The load branch carries it too, not just the mint branch."""
path = tmp_path / "device_id"
path.write_text("existing-uuid\n", encoding="utf-8")
identity = load_device_identity(path, legacy_id="pc")
assert (identity.device_id, identity.legacy_id) == ("existing-uuid", "pc")
def test_own_ids_includes_both_ids_when_migrating() -> None:
"""Skip-own must match the old path as well as the new one."""
identity = DeviceIdentity(device_id="new-uuid", legacy_id="pc")
assert identity.own_ids == frozenset({"new-uuid", "pc"})
def test_own_ids_is_just_the_uuid_once_the_old_path_is_reclaimed() -> None:
"""After GC there is no legacy id left to skip."""
assert DeviceIdentity(device_id="new-uuid").own_ids == frozenset({"new-uuid"})
def test_is_own_matches_either_id() -> None:
"""Both the current and the former id mean "this device"."""
identity = DeviceIdentity(device_id="new-uuid", legacy_id="pc")
assert identity.is_own("new-uuid")
assert identity.is_own("pc")
assert not identity.is_own("phone")