Homelabtoolkit v2
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user