diff --git a/kj-controller/docs/CHANGELOG.md b/kj-controller/docs/CHANGELOG.md index 6e1b2dc..6de6966 100644 --- a/kj-controller/docs/CHANGELOG.md +++ b/kj-controller/docs/CHANGELOG.md @@ -4,6 +4,20 @@ Dated entries, newest first. Each entry notes any required deploy steps. --- +## 2026-08-28 - Merging singers unifies their identity so self-rename doesn't re-split (v0.98.0) + +**Deploy:** backend (`sing.py`, `sing_store.py`, `routes.py`) → **requires `systemctl restart kj-controller`** (backend change; deploy between songs). Additive SQLite migration runs on boot (new `singer_aliases.origin` column, defaults `'self'`) — no manual step. + +- **Why:** a singer submitted from two browser sessions (two stable `device_id`s) under two typed variants — "Jasmine" and "Jasmine!". The KJ merged them into one displayed singer, which correctly unified the *rotation entries* but not the *identity*: when she then renamed herself on her phone, `/sing/rename` only rewrote the songs THAT one device owned (per-request `edit_token`), so the other session's song stayed under the old name and she **re-split** into two singers, only one renamed. A KJ merge is a deliberate assertion that these are one person — a later rename has to carry the whole group. +- **What changed — aliases now carry provenance:** `singer_aliases` gains an `origin` column — `'kj'` when a KJ rename/merge established the identity vs `'self'` for a singer's own `/sing/rename`. `'kj'` is *sticky* (a later self-rename never downgrades it). Only `'kj'` aliases mark a **canonical identity**, so a singer self-renaming their own song can never gain the power to rename a coincidental same-name walk-in. +- **What changed — merge marks a canonical identity:** `POST /rotation/singer/merge` already aliased the *source* devices onto the target (`persist_rename`); it now also calls `SingStore.mark_identity(target)`, tagging the target's own devices `'kj'` so the *keep* side is recognised as the same established identity too. +- **What changed — self-rename escalates for an established identity:** `/sing/rename` now decides per old-name. If `SingStore.is_canonical_identity(old)` (a KJ `'kj'` alias exists) **and** a night marker is available to scope the request rewrite, the rename carries the **whole rotation name-group** (`rename_singer`), migrates **every** device aliased to the old name (`remap_aliases`), and rewrites tonight's requests (`persist_rename`). Otherwise it *fails closed* to the edit_token-owned scope exactly as before — so two coincidental same-name walk-ins can never rename each other, and a missing night marker never clobbers prior nights' history. +- **New `SingStore` helpers:** `is_canonical_identity(name)` (a `'kj'` alias → this name?), `remap_aliases(old, new)` (re-point a whole identity group's device aliases), `mark_identity(name, night_started)` (tag tonight's devices under a name `'kj'`). `set_alias` gains an `origin` arg. All case-insensitive, best-effort, no-ops on blank input. +- **Migration safety:** pre-upgrade alias rows can't be told apart, so they default to `'self'` — the safe choice (they behave exactly as before and never sweep up a same-name singer). A merge done after the upgrade writes fresh `'kj'` aliases, so the fix applies going forward. +- **Tests:** 8 new unit tests (`test_sing_store.py` — the three helpers + `'kj'`-only identity + sticky-origin, incl. same-name/blank no-ops and device-less skips) + 4 new integration tests (`test_sing_rename.py::TestMergedIdentitySelfRename` — merge→self-rename renames the whole group, migrates all device aliases, the no-merge control that stays scoped, and a double-self-rename-through-a-shared-name that must not hijack the other singer). Full unit + integration suite green. + +--- + ## 2026-08-27 - Persistent singer rename — self-service + KJ-side both stick (v0.97.0) **Deploy:** backend (`sing.py`, `sing_store.py`, `rotation.py`, `rotation_store.py`, `routes.py`) + frontend (`static-sing/sing.js`, `sing.css`) → **requires `systemctl restart kj-controller`** (backend change; deploy between songs). Additive SQLite migration runs on boot (new `sing_requests.device_id` column + `singer_aliases` table) — no manual step. diff --git a/kj-controller/pyproject.toml b/kj-controller/pyproject.toml index 73c4422..96afb15 100644 --- a/kj-controller/pyproject.toml +++ b/kj-controller/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kj-controller" -version = "0.97.0" +version = "0.98.0" description = "Web-based karaoke show management with mpv + VLC playback" requires-python = ">=3.11" diff --git a/kj-controller/routes.py b/kj-controller/routes.py index aed537d..98d9a3f 100644 --- a/kj-controller/routes.py +++ b/kj-controller/routes.py @@ -4362,7 +4362,17 @@ def merge_singers_route(): return jsonify({"error": "source_name and target_name are required"}), 400 try: rotation.merge_singers(source, target) + # Alias the SOURCE devices onto the target (persist_rename) AND record the + # TARGET's own devices as the same established identity (mark_identity), so + # a later self-service rename from EITHER side carries the whole merged + # group instead of re-splitting the singer. _persist_singer_rename(source, target) + store = getattr(current_app, 'sing_store', None) + if store is not None: + try: + store.mark_identity(target, night_started=store.get_night_started_at()) + except Exception: + current_app.logger.exception("merge mark_identity failed") return _singer_action_response(rotation) except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/kj-controller/sing.py b/kj-controller/sing.py index 1406bc9..c1ba289 100644 --- a/kj-controller/sing.py +++ b/kj-controller/sing.py @@ -1174,6 +1174,7 @@ def rename_me(): # requests count — a device can never rename someone else's entries. entry_ids_by_old = {} verified_request_ids = [] + verified_old_names = set() for it in items: if not isinstance(it, dict): continue @@ -1190,21 +1191,45 @@ def rename_me(): continue verified_request_ids.append(rid) old = (req.get("singer_name") or "").strip() - if ( - old - and old.lower() != new_name.lower() - and req.get("status") == "approved" - and req.get("linked_entry_id") - ): - entry_ids_by_old.setdefault(old, []).append(req["linked_entry_id"]) - - # Rewrite the rotation entries the singer owns. - if rotation is not None: - for old, eids in entry_ids_by_old.items(): - try: - rotation.rename_singer_in_entries(old, new_name, eids) - except Exception: - current_app.logger.exception("self-rename: entry rewrite failed") + if old and old.lower() != new_name.lower(): + verified_old_names.add(old) + if req.get("status") == "approved" and req.get("linked_entry_id"): + entry_ids_by_old.setdefault(old, []).append(req["linked_entry_id"]) + + # Rewrite the rotation entries. Two modes, decided per old-name: + # + # • Established identity (a KJ merged/renamed this singer into ``old``): + # the singer is deliberately asserted to be ONE person, so a rename must + # carry the WHOLE name-group across the rotation — not just the songs this + # one device owns — else she re-splits under the stale name (the reported + # "Jasmine" / "Jasmine!" bug). We also migrate every device aliased to + # ``old`` and rewrite tonight's requests so no session reverts later. + # • Otherwise (a plain typed name, no merge): stay scoped to edit_token-owned + # entries so two coincidental same-name walk-ins never rename each other. + night_started = None + try: + night_started = store.get_night_started_at() + except Exception: + current_app.logger.exception("self-rename: night lookup failed") + + for old in verified_old_names: + try: + # Escalate to a whole-group rename ONLY for a KJ-established identity + # AND only when we have a night marker to scope the request rewrite — + # without one, persist_rename would touch every historical request + # under this name, so we fail closed to the safe edit_token-scoped + # path rather than risk clobbering prior nights. + if store.is_canonical_identity(old) and night_started: + if rotation is not None: + rotation.rename_singer(old, new_name) + store.persist_rename(old, new_name, night_started=night_started) + store.remap_aliases(old, new_name) + elif rotation is not None and old in entry_ids_by_old: + rotation.rename_singer_in_entries( + old, new_name, entry_ids_by_old[old] + ) + except Exception: + current_app.logger.exception("self-rename: entry rewrite failed") # Rewrite the verified requests' stored name (keeps provenance + the done # screen consistent, and means a pending request is approved under the new diff --git a/kj-controller/sing_store.py b/kj-controller/sing_store.py index d16d896..602ee32 100644 --- a/kj-controller/sing_store.py +++ b/kj-controller/sing_store.py @@ -161,9 +161,16 @@ def init_schema(self): -- once and stores in localStorage. Persists across nights on purpose -- (a regular keeps their chosen name) — device_id is stable per -- browser, so there's no cross-night id-reuse hazard here. + -- `origin` (2026-08-28) records who established the alias: 'kj' for a + -- KJ rename/merge (a deliberate "these are one person" assertion) vs + -- 'self' for a singer's own /sing/rename. Only 'kj' aliases mark a + -- CANONICAL identity that a later self-rename may propagate across the + -- whole name-group — a singer self-renaming their own songs must never + -- gain the power to rename a coincidental same-name walk-in. CREATE TABLE IF NOT EXISTS singer_aliases ( device_id TEXT PRIMARY KEY, canonical_name TEXT NOT NULL, + origin TEXT NOT NULL DEFAULT 'self', updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); """ @@ -221,6 +228,20 @@ def init_schema(self): except sqlite3.OperationalError as e: if "duplicate column name" not in str(e).lower(): raise + # Additive migration — `singer_aliases.origin` (2026-08-28). Distinguishes + # KJ-established identities ('kj', from a rename/merge) from a singer's own + # self-rename ('self'). Only 'kj' unlocks a whole-group rename. Pre-upgrade + # rows can't be told apart, so they default to 'self' — the SAFE choice: + # they behave exactly as before (edit_token-scoped renames) and never gain + # the power to sweep up a coincidental same-name singer. A merge done after + # the upgrade writes fresh 'kj' aliases, so the fix applies going forward. + try: + conn.execute( + "ALTER TABLE singer_aliases ADD COLUMN origin TEXT NOT NULL DEFAULT 'self'" + ) + except sqlite3.OperationalError as e: + if "duplicate column name" not in str(e).lower(): + raise conn.commit() # ------------------------------------------------------------------ @@ -740,20 +761,33 @@ def get_alias(self, device_id): ).fetchone() return row[0] if row else None - def set_alias(self, device_id, canonical_name): - """Upsert a device → canonical-name mapping. No-op on blank input.""" + def set_alias(self, device_id, canonical_name, origin="self"): + """Upsert a device → canonical-name mapping. No-op on blank input. + + ``origin`` is 'kj' when a KJ rename/merge established the identity, else + 'self' (a singer's own /sing/rename). The stored origin always reflects + the LAST writer: a KJ action stamps 'kj'; a self-rename stamps 'self'. + Crucially, KJ authority does NOT travel with a device onto a name the + SINGER later chose — a self-rename that changes the name resets origin to + 'self', so a singer can never launder a past merge into whole-group power + over a coincidental same-name walk-in. Only 'kj' aliases satisfy + is_canonical_identity; a genuinely KJ-merged multi-device identity stays + canonical via the sibling devices the KJ/merge path re-stamps 'kj'. + """ device_id = (device_id or "").strip() canonical_name = (canonical_name or "").strip() + origin = "kj" if origin == "kj" else "self" if not device_id or not canonical_name: return conn = self._get_conn() conn.execute( - "INSERT INTO singer_aliases (device_id, canonical_name, updated_at) " - "VALUES (?, ?, datetime('now', 'localtime')) " + "INSERT INTO singer_aliases (device_id, canonical_name, origin, updated_at) " + "VALUES (?, ?, ?, datetime('now', 'localtime')) " "ON CONFLICT(device_id) DO UPDATE SET " " canonical_name = excluded.canonical_name, " + " origin = excluded.origin, " " updated_at = datetime('now', 'localtime')", - (device_id, canonical_name), + (device_id, canonical_name, origin), ) conn.commit() @@ -768,6 +802,86 @@ def clear_alias(self, device_id): ) conn.commit() + def is_canonical_identity(self, name): + """True if ``name`` is a KJ-established singer identity. + + An identity is "established" only when at least one device carries a + KJ-origin alias to ``name`` — i.e. a KJ renamed/merged someone into it. + This is the trust anchor that lets a self-service rename safely carry the + WHOLE name-group rather than only the calling device's own songs. + + A singer's OWN /sing/rename records a 'self'-origin alias, which does NOT + count here — otherwise a singer who self-renamed their song to "Mike" + could, on a second rename, sweep up a coincidental second "Mike" walk-in + whose edit_token they never held. Only the KJ's explicit assertion that a + name is one managed identity unlocks whole-group renames. + """ + name = (name or "").strip() + if not name: + return False + conn = self._get_conn() + row = conn.execute( + "SELECT 1 FROM singer_aliases " + "WHERE LOWER(canonical_name) = LOWER(?) AND origin = 'kj' LIMIT 1", + (name,), + ).fetchone() + return row is not None + + def remap_aliases(self, old_name, new_name): + """Re-point every device aliased to ``old_name`` at ``new_name``. + + Used when a merged identity is renamed: all the devices the KJ merged + into ``old_name`` must follow the singer to ``new_name`` so none of them + re-splits under the stale name on a future submission. Cross-night like + the aliases themselves (device_id is stable per browser). No-op on blank + input or a no-op rename. + """ + old_name = (old_name or "").strip() + new_name = (new_name or "").strip() + if not old_name or not new_name or old_name.lower() == new_name.lower(): + return + conn = self._get_conn() + conn.execute( + "UPDATE singer_aliases SET canonical_name = ?, " + " updated_at = datetime('now', 'localtime') " + "WHERE LOWER(canonical_name) = LOWER(?)", + (new_name, old_name), + ) + conn.commit() + + def mark_identity(self, name, night_started=None): + """Alias every device that submitted under ``name`` tonight → ``name``. + + Records the devices behind a name as one established identity (a + KJ-origin alias). Called on the KEEP side of a KJ merge so the merged + singer is a recognised identity even from a device that never had to be + renamed — which is what makes a later self-service rename carry the group. + Best-effort; returns the number of devices marked. + + Night-scoped like the rest of request/phone resolution. The marker is + resolved internally when not passed; with no night at all we fail closed + (return 0) rather than mark every historical device under ``name``. + """ + name = (name or "").strip() + if not name: + return 0 + night_started = night_started or self.get_night_started_at() + if not night_started: + return 0 + conn = self._get_conn() + params = [name, night_started] + night_clause = " AND created_at >= ?" + rows = conn.execute( + "SELECT DISTINCT device_id FROM sing_requests " + "WHERE LOWER(singer_name) = LOWER(?)" + " AND device_id IS NOT NULL AND device_id != ''" + night_clause, + tuple(params), + ).fetchall() + device_ids = [r[0] for r in rows] + for did in device_ids: + self.set_alias(did, name, origin="kj") + return len(device_ids) + def persist_rename(self, old_name, new_name, night_started=None): """Make a KJ/merge rename of ``old_name`` → ``new_name`` sticky. @@ -779,20 +893,22 @@ def persist_rename(self, old_name, new_name, night_started=None): the current rotation-entry name) keeps working after the rename. Night-scoped (``created_at >= night_started``) like the rest of the - request/phone resolution to avoid touching prior nights' history. Returns - the number of distinct devices aliased. Best-effort; safe to call for a - name that has no portal submissions (returns 0). + request/phone resolution to avoid touching prior nights' history. The + marker is resolved internally when not passed; with no night at all we + fail closed (return 0) rather than rewrite every historical request under + ``old_name``. Returns the number of distinct devices aliased. Best-effort; + safe to call for a name that has no portal submissions (returns 0). """ old_name = (old_name or "").strip() new_name = (new_name or "").strip() if not old_name or not new_name or old_name.lower() == new_name.lower(): return 0 + night_started = night_started or self.get_night_started_at() + if not night_started: + return 0 conn = self._get_conn() - params = [old_name] - night_clause = "" - if night_started: - night_clause = " AND created_at >= ?" - params.append(night_started) + params = [old_name, night_started] + night_clause = " AND created_at >= ?" rows = conn.execute( "SELECT DISTINCT device_id FROM sing_requests " "WHERE LOWER(singer_name) = LOWER(?)" @@ -801,7 +917,8 @@ def persist_rename(self, old_name, new_name, night_started=None): ).fetchall() device_ids = [r[0] for r in rows] for did in device_ids: - self.set_alias(did, new_name) + # A KJ/merge rename establishes a managed identity (origin='kj'). + self.set_alias(did, new_name, origin="kj") # Rewrite the tonight requests' primary singer_name for provenance. conn.execute( "UPDATE sing_requests SET singer_name = ? " diff --git a/kj-controller/tests/integration/test_sing_rename.py b/kj-controller/tests/integration/test_sing_rename.py index 13b7ba8..26f62b8 100644 --- a/kj-controller/tests/integration/test_sing_rename.py +++ b/kj-controller/tests/integration/test_sing_rename.py @@ -195,3 +195,125 @@ def test_kj_merge_sets_alias(self, client, auto_approve, token): ) assert resp.status_code == 200 assert sing_app.sing_store.get_alias("dev-rob") == "Rob" + + +class TestMergedIdentitySelfRename: + """After a KJ merges two name-variants into one singer, that singer is ONE + identity. A subsequent self-rename from any of her devices must carry the + WHOLE merged group — not just the entries the one calling device owns — + otherwise the singer re-splits under two names (the reported "Jasmine" / + "Jasmine!" bug). + """ + + def _singer_names(self, sing_app): + entries = sing_app.rotation.store.get_all_entries() + return sorted(e["singer"] for e in entries) + + def test_self_rename_after_merge_renames_whole_group( + self, client, auto_approve, token + ): + sing_app = auto_approve + # Same person, two browser sessions → two device_ids, two typed variants. + r1 = _submit(client, token, device_id="dev-jas-a", + singer_name="Jasmine", title="Song A") + r2 = _submit(client, token, device_id="dev-jas-b", + singer_name="Jasmine!", title="Song B") + e1 = r1.get_json()["request"]["linked_entry_id"] + e2 = r2.get_json()["request"]["linked_entry_id"] + assert e1 and e2 + + # KJ merges the two variants into one displayed singer. + assert client.post( + "/rotation/singer/merge", + json={"source_name": "Jasmine", "target_name": "Jasmine!"}, + ).status_code == 200 + assert self._singer_names(sing_app) == ["Jasmine!", "Jasmine!"] + + # She renames herself on ONE phone (device A only knows its own song). + rr = r1.get_json()["request"] + resp = client.post( + f"/sing/rename?t={token}", + json={"new_name": "Jazz", "device_id": "dev-jas-a", + "items": [{"id": rr["id"], "edit_token": rr["edit_token"]}]}, + ) + assert resp.status_code == 200 + + # BOTH rotation entries must now read "Jazz" — no re-split. + assert self._singer_names(sing_app) == ["Jazz", "Jazz"] + assert sing_app.rotation.store.get_entry(e1)["singer"] == "Jazz" + assert sing_app.rotation.store.get_entry(e2)["singer"] == "Jazz" + + def test_self_rename_after_merge_migrates_all_device_aliases( + self, client, auto_approve, token + ): + sing_app = auto_approve + _submit(client, token, device_id="dev-jas-a", + singer_name="Jasmine", title="Song A") + r2 = _submit(client, token, device_id="dev-jas-b", + singer_name="Jasmine!", title="Song B") + client.post("/rotation/singer/merge", + json={"source_name": "Jasmine", "target_name": "Jasmine!"}) + + rr2 = r2.get_json()["request"] + client.post( + f"/sing/rename?t={token}", + json={"new_name": "Jazz", "device_id": "dev-jas-b", + "items": [{"id": rr2["id"], "edit_token": rr2["edit_token"]}]}, + ) + # Both devices' aliases follow the new name so neither re-splits on a + # future submission. + assert sing_app.sing_store.get_alias("dev-jas-a") == "Jazz" + assert sing_app.sing_store.get_alias("dev-jas-b") == "Jazz" + + def test_self_rename_without_merge_stays_scoped(self, client, auto_approve, token): + """No merge ⇒ no shared identity: two coincidental same-name walk-ins + must NOT rename each other. The escalation only fires for a KJ-merged + identity.""" + sing_app = auto_approve + r_a = _submit(client, token, device_id="dev-mike-a", + singer_name="Mike", title="Song A") + r_b = _submit(client, token, device_id="dev-mike-b", + singer_name="Mike", title="Song B") + e_b = r_b.get_json()["request"]["linked_entry_id"] + + rr = r_a.get_json()["request"] + client.post( + f"/sing/rename?t={token}", + json={"new_name": "Mike A", "device_id": "dev-mike-a", + "items": [{"id": rr["id"], "edit_token": rr["edit_token"]}]}, + ) + # Only device A's own entry renamed; the other Mike is untouched. + assert sing_app.rotation.store.get_entry(e_b)["singer"] == "Mike" + assert sing_app.sing_store.get_alias("dev-mike-b") is None + + def test_double_self_rename_never_hijacks_a_coincidental_name( + self, client, auto_approve, token + ): + """A singer's OWN alias must never unlock a whole-group rename. Device A + self-renames into "Mike" (creating a 'self' alias), then self-renames + again FROM "Mike" — the independent "Mike" walk-in (device B) must stay + untouched because only a KJ merge establishes a shared identity.""" + sing_app = auto_approve + r_a = _submit(client, token, device_id="dev-alpha", + singer_name="Alpha", title="Song A") + r_b = _submit(client, token, device_id="dev-beta", + singer_name="Mike", title="Song B") + e_b = r_b.get_json()["request"]["linked_entry_id"] + + # First self-rename: Alpha → Mike (collides with the other singer's name). + rr = r_a.get_json()["request"] + client.post( + f"/sing/rename?t={token}", + json={"new_name": "Mike", "device_id": "dev-alpha", + "items": [{"id": rr["id"], "edit_token": rr["edit_token"]}]}, + ) + # Second self-rename from the now-shared name "Mike" → "Mikey". + rr2 = sing_app.sing_store.get_request(rr["id"]) + client.post( + f"/sing/rename?t={token}", + json={"new_name": "Mikey", "device_id": "dev-alpha", + "items": [{"id": rr["id"], "edit_token": rr2["edit_token"]}]}, + ) + # Device B's genuinely-separate "Mike" entry must NOT have been renamed. + assert sing_app.rotation.store.get_entry(e_b)["singer"] == "Mike" + assert sing_app.sing_store.get_alias("dev-beta") is None diff --git a/kj-controller/tests/unit/test_sing_store.py b/kj-controller/tests/unit/test_sing_store.py index 8ebf6d2..0b25c3f 100644 --- a/kj-controller/tests/unit/test_sing_store.py +++ b/kj-controller/tests/unit/test_sing_store.py @@ -921,3 +921,66 @@ def test_persist_rename_skips_requests_without_device(self, store): singer_name="Nomad", phone="", source_type="local", source_ref="/a.mp4", ) assert store.persist_rename("Nomad", "Nomad K", night_started=night) == 0 + + def test_is_canonical_identity_only_counts_kj_origin(self, store): + assert store.is_canonical_identity("Jasmine!") is False + # A singer's own self-rename alias ('self') does NOT establish an identity. + store.set_alias("dev-a", "Jasmine!") + assert store.is_canonical_identity("Jasmine!") is False + # A KJ rename/merge alias ('kj') does. + store.set_alias("dev-b", "Jasmine!", origin="kj") + assert store.is_canonical_identity("Jasmine!") is True + # Case-insensitive; blank is never an identity. + assert store.is_canonical_identity("jasmine!") is True + assert store.is_canonical_identity("") is False + assert store.is_canonical_identity(None) is False + + def test_set_alias_self_update_resets_kj_authority(self, store): + # KJ authority does NOT travel onto a name the SINGER later chose: a + # self-rename resets origin to 'self', so a past merge can't be laundered + # into whole-group power over a coincidental same-name walk-in. + store.set_alias("dev-a", "Jasmine!", origin="kj") + assert store.is_canonical_identity("Jasmine!") is True + store.set_alias("dev-a", "Jazz") # singer self-rename → 'self' + assert store.get_alias("dev-a") == "Jazz" + assert store.is_canonical_identity("Jazz") is False + # A brand-new self alias stays 'self'. + store.set_alias("dev-c", "Chris") + assert store.is_canonical_identity("Chris") is False + + def test_remap_aliases_repoints_whole_group(self, store): + store.set_alias("dev-a", "Jasmine!") + store.set_alias("dev-b", "Jasmine!") + store.set_alias("dev-c", "Someone Else") + store.remap_aliases("Jasmine!", "Jazz") + assert store.get_alias("dev-a") == "Jazz" + assert store.get_alias("dev-b") == "Jazz" + # Devices in a different identity are untouched. + assert store.get_alias("dev-c") == "Someone Else" + + def test_remap_aliases_noops(self, store): + store.set_alias("dev-a", "Jasmine!") + store.remap_aliases("Jasmine!", "jasmine!") # same name (case-insensitive) + assert store.get_alias("dev-a") == "Jasmine!" + store.remap_aliases("", "X") + store.remap_aliases("Jasmine!", "") + assert store.get_alias("dev-a") == "Jasmine!" + + def test_mark_identity_aliases_tonight_devices(self, store): + night = store.ensure_night_started() + store.create_request( + singer_name="Jasmine!", phone="", source_type="local", + source_ref="/a.mp4", device_id="dev-jas", + ) + n = store.mark_identity("Jasmine!", night_started=night) + assert n == 1 + assert store.get_alias("dev-jas") == "Jasmine!" + assert store.is_canonical_identity("Jasmine!") is True + + def test_mark_identity_skips_requests_without_device(self, store): + night = store.ensure_night_started() + store.create_request( + singer_name="Nomad", phone="", source_type="local", source_ref="/a.mp4", + ) + assert store.mark_identity("Nomad", night_started=night) == 0 + assert store.is_canonical_identity("Nomad") is False