Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+8
View File
@@ -0,0 +1,8 @@
import importlib
import sys
def test_app_module_imports_cleanly():
sys.modules.pop("app", None)
module = importlib.import_module("app")
assert module.app is not None
+104
View File
@@ -0,0 +1,104 @@
import json
import sqlite3
from services import homescreen_editor
def _make_db(path):
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE Users (Id INTEGER PRIMARY KEY, Name TEXT, Guid TEXT)")
conn.execute("CREATE TABLE UserSettingsKeys (UserSettingsKeyId INTEGER PRIMARY KEY, Name TEXT)")
conn.execute("CREATE TABLE UserSettings (UserId INTEGER, UserSettingsKeyId INTEGER, Value TEXT)")
conn.execute("INSERT INTO UserSettingsKeys (UserSettingsKeyId, Name) VALUES (1, 'homescreensettings')")
conn.execute("INSERT INTO Users (Id, Name, Guid) VALUES (1, 'Alice', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')")
conn.execute("INSERT INTO Users (Id, Name, Guid) VALUES (2, 'Bob', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')")
conn.execute(
"INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)",
(
1,
1,
json.dumps(
{
"Sections": [
{
"Id": "one",
"Name": "Watchlist",
"CustomName": "Watchlist",
"UserId": "WRONG",
"SectionType": "items",
}
]
}
),
),
)
conn.commit()
conn.close()
def test_read_db_normalizes_section_user_ids(tmp_path):
db_path = tmp_path / "users.db"
_make_db(db_path)
result = homescreen_editor.read_db(str(db_path))
assert result["validation"]["userCount"] == 2
alice = next(user for user in result["users"] if user["name"] == "Alice")
assert alice["embyGuid"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
assert alice["sections"][0]["UserId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
def test_write_db_persists_normalized_sections(tmp_path):
db_path = tmp_path / "users.db"
_make_db(db_path)
payload = homescreen_editor.write_db(
str(db_path),
[
{
"userId": 2,
"sections": [
{
"Id": "two",
"Name": "Recent",
"CustomName": "Recent",
"UserId": "SHOULD_BE_NORMALIZED",
"SectionType": "items",
}
],
}
],
)
assert payload["ok"] is True
conn = sqlite3.connect(db_path)
value = conn.execute("SELECT Value FROM UserSettings WHERE UserId = 2").fetchone()[0]
conn.close()
parsed = json.loads(value)
assert parsed["Sections"][0]["UserId"] == "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
def test_generate_sql_includes_only_changed_users():
original = [{"id": 1, "name": "Alice", "sections": [{"Id": "one"}]}]
updated = [{"id": 1, "name": "Alice", "sections": [{"Id": "two"}]}]
sql = homescreen_editor.generate_sql(updated, original)
assert "Alice" in sql
assert "UPDATE UserSettings" in sql
assert "COMMIT;" in sql
def test_uploaded_db_becomes_active_source(tmp_path, monkeypatch):
upload_dir = tmp_path / "uploads"
state_path = tmp_path / "upload-state.json"
monkeypatch.setattr(homescreen_editor, "HOMESCREEN_UPLOAD_DIR", upload_dir)
monkeypatch.setattr(homescreen_editor, "HOMESCREEN_UPLOAD_STATE_PATH", state_path)
meta = homescreen_editor.save_uploaded_db("users.db", b"sqlite-bytes")
active = homescreen_editor.get_active_upload()
resolved_path, resolved_meta = homescreen_editor.resolve_db_source()
assert meta["upload_id"] == active["upload_id"]
assert resolved_meta["upload_id"] == meta["upload_id"]
assert resolved_path.endswith(f"{meta['upload_id']}.db")
+72
View File
@@ -0,0 +1,72 @@
from services import self_update
def test_is_local_client_accepts_private_and_loopback_addresses():
assert self_update.is_local_client("127.0.0.1")
assert self_update.is_local_client("10.0.0.124")
assert self_update.is_local_client("192.168.1.20")
assert self_update.is_local_client("::1")
assert self_update.is_local_client("::ffff:10.0.0.124")
def test_is_local_client_rejects_public_and_empty_hosts():
assert not self_update.is_local_client("")
assert not self_update.is_local_client(None)
assert not self_update.is_local_client("8.8.8.8")
assert not self_update.is_local_client("example.com")
def test_status_payload_reports_missing_configuration(tmp_path, monkeypatch):
monkeypatch.setattr(self_update, "is_paramiko_available", lambda: True)
status = self_update.status_payload(
{
"deploy_nas_host": "",
"deploy_nas_user": "",
"deploy_nas_password": "",
"deploy_remote_app_dir": "",
},
"10.0.0.124",
)
assert status["allowed"] is True
assert status["configured"] is False
assert status["available"] is False
assert status["transport"] == "paramiko"
assert status["transport_ready"] is True
assert "Set a NAS host and NAS user first." in (status["reason"] or "")
def test_status_payload_available_when_local_and_configured(monkeypatch):
monkeypatch.setattr(self_update, "is_paramiko_available", lambda: True)
status = self_update.status_payload(
{
"deploy_nas_host": "MATT-NAS",
"deploy_nas_user": "ssh",
"deploy_nas_password": "secret",
"deploy_remote_app_dir": "/share/Docker/homelabtoolkit",
},
"10.0.0.124",
)
assert status["allowed"] is True
assert status["configured"] is True
assert status["transport"] == "paramiko"
assert status["transport_ready"] is True
assert status["password_configured"] is True
assert status["available"] is True
def test_render_remote_compose_uses_deploy_settings():
rendered = self_update.render_remote_compose(
{
"deploy_remote_app_dir": "/share/Docker/custom-toolkit",
"deploy_music_host_path": "/share/Movies/Music",
}
)
assert "/share/Docker/custom-toolkit/output:/app/output" in rendered
assert "/share/Docker/custom-toolkit/cache:/app/cache" in rendered
assert "/share/Movies/Music:/music" in rendered
assert "/share/Music:/music" not in rendered
+57
View File
@@ -0,0 +1,57 @@
import asyncio
from pathlib import Path
from services import emby_tasks
from services import music_covers as music_service
def run(coro):
return asyncio.run(coro)
def test_describe_tasks_marks_navidrome_tasks_unavailable_without_music_root(tmp_path, monkeypatch):
monkeypatch.setattr(music_service, "MUSIC_ROOT", tmp_path / "missing-music-root")
settings = emby_tasks.default_settings()
tasks = {task["id"]: task for task in emby_tasks.describe_tasks(settings)}
assert tasks["navidrome_file_cleanup"]["supports_run"] is False
assert tasks["navidrome_cover_backfill"]["requires"] == "music_root"
def test_navidrome_file_cleanup_preview_and_apply(tmp_path, monkeypatch):
music_root = tmp_path / "music"
album = music_root / "Boards of Canada" / "Music Has the Right to Children"
album.mkdir(parents=True)
(album / "cover.jpg").write_bytes(b"cover")
(album / "booklet.pdf").write_bytes(b"pdf")
(album / "notes.nfo").write_text("extra", encoding="utf-8")
monkeypatch.setattr(music_service, "MUSIC_ROOT", music_root)
monkeypatch.setattr(emby_tasks, "STATE_FILE", tmp_path / "tasks-state.json")
settings = emby_tasks.default_settings()
preview = run(emby_tasks.run_task("navidrome_file_cleanup", client=None, settings=settings, dry_run=True))
assert preview["ok"] is True
assert preview["matched_count"] == 2
assert "would be removed" in preview["message"]
assert (album / "booklet.pdf").exists()
assert (album / "notes.nfo").exists()
applied = run(emby_tasks.run_task("navidrome_file_cleanup", client=None, settings=settings, dry_run=False))
assert applied["ok"] is True
assert applied["removed_count"] == 2
assert not (album / "booklet.pdf").exists()
assert not (album / "notes.nfo").exists()
def test_record_run_persists_task_status(tmp_path, monkeypatch):
monkeypatch.setattr(emby_tasks, "STATE_FILE", tmp_path / "tasks-state.json")
entry = emby_tasks.record_run("navidrome_file_cleanup", {"ok": True, "message": "done"}, automated=True)
state = emby_tasks.load_state()
assert entry["last_status"] == "ok"
assert state["tasks"]["navidrome_file_cleanup"]["last_result"]["message"] == "done"
assert "last_automation_week" in state["tasks"]["navidrome_file_cleanup"]