208 lines
7.1 KiB
Python
208 lines
7.1 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""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"
|