Files
UmbraAudio/tests/test_delete_speaker.py
B.D. Murphy 970e6e884a Implement DELETE /api/speakers/{index}; the Delete button did nothing
The speakers page has carried a Delete button that called
DELETE /api/speakers/{index}. That endpoint did not exist - the server
answered 405 Method Not Allowed and the button silently did nothing.

Deleting is more than dropping an entry, so the endpoint refuses to orphan
dialogue: a speaker with utterances returns 409 with the count and the list
of possible targets, and the UI asks where that dialogue should go.
reassign_to then moves every utterance recorded under the speaker's name OR
any of its aliases - utterances are frequently stored under an alias - and
keeps the old name as an alias on the target so existing references still
resolve. A speaker with no dialogue is simply removed.

Also removes the speaker's own reference audio (never the shared narrator or
general ref) and its voice_status approval entry, so a speaker re-created
later cannot inherit an approval nobody gave for it.

The button is no longer hx-delete: htmx does not swap a 409, and deleting
renumbers every speaker after it, so the handler re-renders the whole list.

Verified live on Deal Hunter, reversibly: create throwaway -> delete (200,
gone) -> recreate with an utterance attached -> delete (409, count 1, 61
targets offered) -> reassign_to=Warbots (200, utterance restored, name kept
as alias) -> compared against a pre-test backup, speaker count and utterance
count identical.

10 new tests. Suite: 708 passed, 8 pre-existing.
2026-10-11 21:37:59 -07:00

205 lines
7.0 KiB
Python

"""Tests for speaker deletion.
The speakers page has offered a Delete button for a long time. The endpoint
it called, ``DELETE /api/speakers/{index}``, did not exist: htmx sent the
request and the server answered ``405 Method Not Allowed``, so the button
silently did nothing.
Deleting a speaker who still has dialogue is also destructive in a way that
is easy to miss - the utterances keep a ``speaker_name`` that no longer
resolves - so the endpoint refuses unless the caller says where that dialogue
should go.
"""
from __future__ import annotations
import json
import sqlite3
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
SPEAKERS = [
{"primary_name": "Alpha", "aliases": ["A-one"], "vocal_traits": ["calm"]},
{"primary_name": "Beta", "aliases": [], "vocal_traits": ["deep"]},
{"primary_name": "Silent", "aliases": [], "vocal_traits": []},
]
@pytest.fixture()
def data_dir(tmp_path: Path) -> Iterator[Path]:
"""A throwaway project, wired in as the active one."""
from umbraaudio.config import AppConfig
d = tmp_path / "proj" / ".umbra"
d.mkdir(parents=True)
(d / "speakers.json").write_text(json.dumps({"speakers": SPEAKERS}))
# Use the app's own schema: _get_db runs SCHEMA_SQL on connect, and a
# hand-rolled minimal table fails against it.
from umbraaudio.web.routes import SCHEMA_SQL
conn = sqlite3.connect(str(d / "project.db"))
conn.executescript(SCHEMA_SQL)
conn.execute(
"INSERT INTO utterances (chapter_id, utterance_index, speaker_name, text, type) "
"VALUES (1, 0, 'Beta', 'hello', 'dialogue')"
)
conn.execute(
"INSERT INTO utterances (chapter_id, utterance_index, speaker_name, text, type) "
"VALUES (1, 1, 'A-one', 'via alias', 'dialogue')"
)
conn.commit()
conn.close()
cfg = AppConfig()
cfg.project_dir = tmp_path / "proj"
cfg.data_dir = d
import umbraaudio.config as config_mod
original = config_mod.load_config
config_mod.load_config = lambda _slug=None: cfg # type: ignore[assignment]
try:
yield d
finally:
config_mod.load_config = original # type: ignore[assignment]
@pytest.fixture()
def client(data_dir: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
from umbraaudio.config import AppConfig
from umbraaudio.web import create_app
cfg = AppConfig()
cfg.project_dir = data_dir.parent
cfg.data_dir = data_dir
# The app is gated by fail-closed auth middleware, so an unauthenticated
# request is redirected to /login and TestClient follows it - which would
# turn every assertion into a check against the login page. Mint a real
# session with a pinned secret so the tests neither touch the production
# .umbra/session_secret nor need the real password.
import umbraaudio.auth as auth_mod
monkeypatch.setattr(auth_mod, "_session_secret", lambda: b"k" * 32)
token = auth_mod.issue_session(auth_mod.USERNAME)
# create_app caches the app in a module-level singleton, so it must be
# cleared between tests or the second test would reuse the first test's
# config - and therefore the first test's temporary directory.
import umbraaudio.web as web_mod
web_mod._app = None
try:
c = TestClient(create_app(cfg), follow_redirects=False)
c.cookies.set("active_project", "testproj")
c.cookies.set(auth_mod.COOKIE_NAME, token)
yield c
finally:
web_mod._app = None
def _spk(data_dir: Path) -> list[dict[str, Any]]:
return json.loads((data_dir / "speakers.json").read_text())["speakers"]
def _names(data_dir: Path) -> list[str]:
return [s["primary_name"] for s in _spk(data_dir)]
def _rows(data_dir: Path) -> dict[str, str]:
conn = sqlite3.connect(str(data_dir / "project.db"))
rows = dict(conn.execute("SELECT text, speaker_name FROM utterances"))
conn.close()
return rows
def test_delete_speaker_without_dialogue_succeeds(
client: TestClient, data_dir: Path
) -> None:
idx = _names(data_dir).index("Silent")
assert client.delete(f"/api/speakers/{idx}").status_code == 200
assert "Silent" not in _names(data_dir)
def test_delete_speaker_with_dialogue_is_refused(
client: TestClient, data_dir: Path
) -> None:
"""Otherwise the utterances keep a name that no longer resolves."""
idx = _names(data_dir).index("Beta")
r = client.delete(f"/api/speakers/{idx}")
assert r.status_code == 409
body = r.json()
assert body["utterance_count"] == 1
assert "Beta" in body["error"]
assert "Beta" in _names(data_dir), "must not be removed"
def test_refusal_offers_the_other_speakers_as_targets(
client: TestClient, data_dir: Path
) -> None:
idx = _names(data_dir).index("Beta")
options = client.delete(f"/api/speakers/{idx}").json()["options"]
assert "Alpha" in options
assert "Beta" not in options, "a speaker cannot be reassigned to itself"
def test_reassigning_moves_the_dialogue(client: TestClient, data_dir: Path) -> None:
idx = _names(data_dir).index("Beta")
assert client.delete(f"/api/speakers/{idx}?reassign_to=Alpha").status_code == 200
assert _rows(data_dir)["hello"] == "Alpha"
assert "Beta" not in _names(data_dir)
def test_reassignment_keeps_the_old_name_as_an_alias(
client: TestClient, data_dir: Path
) -> None:
"""So anything still referring to the deleted name keeps resolving."""
idx = _names(data_dir).index("Beta")
client.delete(f"/api/speakers/{idx}?reassign_to=Alpha")
alpha = next(s for s in _spk(data_dir) if s["primary_name"] == "Alpha")
assert "Beta" in alpha["aliases"]
def test_dialogue_stored_under_an_alias_also_counts(
client: TestClient, data_dir: Path
) -> None:
"""Utterances are frequently stored under an alias, not the primary name."""
idx = _names(data_dir).index("Alpha")
r = client.delete(f"/api/speakers/{idx}")
assert r.status_code == 409
assert r.json()["utterance_count"] == 1
def test_reassignment_target_matches_case_insensitively(
client: TestClient, data_dir: Path
) -> None:
idx = _names(data_dir).index("Alpha")
assert client.delete(f"/api/speakers/{idx}?reassign_to=beta").status_code == 200
assert _rows(data_dir)["via alias"] == "Beta"
assert "Alpha" not in _names(data_dir)
def test_unknown_reassignment_target_is_rejected(
client: TestClient, data_dir: Path
) -> None:
idx = _names(data_dir).index("Beta")
assert client.delete(f"/api/speakers/{idx}?reassign_to=Nobody").status_code == 400
assert "Beta" in _names(data_dir), "must not delete"
def test_out_of_range_index_is_rejected(client: TestClient) -> None:
assert client.delete("/api/speakers/999").status_code == 400
def test_deleting_leaves_other_speakers_untouched(
client: TestClient, data_dir: Path
) -> None:
idx = _names(data_dir).index("Silent")
client.delete(f"/api/speakers/{idx}")
alpha = next(s for s in _spk(data_dir) if s["primary_name"] == "Alpha")
assert alpha["aliases"] == ["A-one"], "unrelated aliases must be untouched"