Add Forgejo CI: ruff lint + pytest unit + gated image publish
Family-consistent CI for StashHandler (mirrors the NhenArchiver Python sibling, minus the integration lane — StashHandler is SQLite-only with no service containers). - .forgejo/workflows/ci.yml: lint (ruff) -> unit (pytest) -> publish. publish builds + pushes git.fabledsword.com/bvandeusen/stashhandler with the family rule-46 tag scheme (dev->:dev+:c-<sha>, main->:latest+:c-<sha>, v* tag->:<version>+:latest). Gated on lint+unit. Needs REGISTRY_TOKEN. - tests/: starter unit suite — hashing, SQLite dedup, media filter, transfer modes. - pyproject.toml: ruff target + pytest pythonpath/testpaths. - ci-requirements.md (family rule 39), .dockerignore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nyYC9dTQ78SqhpNFtpMvT
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import db
|
||||
|
||||
|
||||
def make_conn(tmp_path):
|
||||
return db.init_db(str(tmp_path / "seen.db"))
|
||||
|
||||
|
||||
def test_record_and_is_known(tmp_path):
|
||||
conn = make_conn(tmp_path)
|
||||
assert not db.is_known(conn, "deadbeef")
|
||||
db.record_file(conn, "deadbeef", "a.mp4", "scan")
|
||||
assert db.is_known(conn, "deadbeef")
|
||||
|
||||
|
||||
def test_record_file_is_idempotent_on_hash(tmp_path):
|
||||
conn = make_conn(tmp_path)
|
||||
db.record_file(conn, "h1", "first.mp4", "scan")
|
||||
db.record_file(conn, "h1", "second.mp4", "scan") # INSERT OR IGNORE
|
||||
row = conn.execute("SELECT filename FROM seen_files WHERE hash='h1'").fetchone()
|
||||
assert row[0] == "first.mp4" # first write wins, no duplicate row
|
||||
|
||||
|
||||
def test_increment_seen(tmp_path):
|
||||
conn = make_conn(tmp_path)
|
||||
db.record_file(conn, "h1", "a.mp4", "scan") # times_seen defaults to 1
|
||||
db.increment_seen(conn, "h1")
|
||||
db.increment_seen(conn, "h1")
|
||||
row = conn.execute("SELECT times_seen FROM seen_files WHERE hash='h1'").fetchone()
|
||||
assert row[0] == 3
|
||||
|
||||
|
||||
def test_processed_torrent_roundtrip(tmp_path):
|
||||
conn = make_conn(tmp_path)
|
||||
assert not db.is_torrent_processed(conn, "ih1")
|
||||
db.record_torrent(conn, "ih1", "Some.Torrent.Name")
|
||||
assert db.is_torrent_processed(conn, "ih1")
|
||||
|
||||
|
||||
def test_processed_file_is_scoped_to_torrent_and_name(tmp_path):
|
||||
conn = make_conn(tmp_path)
|
||||
assert not db.is_file_processed(conn, "ih1", "f.mp4")
|
||||
db.record_processed_file(conn, "ih1", "f.mp4")
|
||||
assert db.is_file_processed(conn, "ih1", "f.mp4")
|
||||
# A different file under the same torrent is tracked independently.
|
||||
assert not db.is_file_processed(conn, "ih1", "other.mp4")
|
||||
# Same file name under a different torrent is also independent.
|
||||
assert not db.is_file_processed(conn, "ih2", "f.mp4")
|
||||
@@ -0,0 +1,42 @@
|
||||
import builtins
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from hasher import hash_file
|
||||
|
||||
|
||||
def test_sha256_matches_hashlib(tmp_path):
|
||||
p = tmp_path / "f.bin"
|
||||
# Larger than the 64KB read chunk so the streaming loop iterates.
|
||||
data = b"hello world\n" * 10000
|
||||
p.write_bytes(data)
|
||||
assert hash_file(p, "sha256") == hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def test_default_algorithm_is_sha256(tmp_path):
|
||||
p = tmp_path / "f.bin"
|
||||
p.write_bytes(b"abc")
|
||||
assert hash_file(p) == hashlib.sha256(b"abc").hexdigest()
|
||||
|
||||
|
||||
def test_blake3_produces_expected_digest(tmp_path):
|
||||
blake3 = pytest.importorskip("blake3")
|
||||
p = tmp_path / "f.bin"
|
||||
p.write_bytes(b"abc")
|
||||
assert hash_file(p, "blake3") == blake3.blake3(b"abc").hexdigest()
|
||||
|
||||
|
||||
def test_blake3_missing_raises_importerror(tmp_path, monkeypatch):
|
||||
p = tmp_path / "f.bin"
|
||||
p.write_bytes(b"abc")
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "blake3":
|
||||
raise ImportError("simulated missing blake3")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
with pytest.raises(ImportError):
|
||||
hash_file(p, "blake3")
|
||||
@@ -0,0 +1,47 @@
|
||||
from pathlib import Path
|
||||
|
||||
import stash_handler
|
||||
|
||||
EXTS = {".mp4", ".mkv"}
|
||||
|
||||
|
||||
def test_is_media_is_case_insensitive():
|
||||
assert stash_handler.is_media(Path("a.mp4"), EXTS)
|
||||
assert stash_handler.is_media(Path("A.MKV"), EXTS)
|
||||
assert not stash_handler.is_media(Path("notes.txt"), EXTS)
|
||||
|
||||
|
||||
def test_walk_media_recurses_and_filters(tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "a.mp4").write_bytes(b"x")
|
||||
(tmp_path / "sub" / "b.mkv").write_bytes(b"x")
|
||||
(tmp_path / "c.txt").write_bytes(b"x")
|
||||
found = {p.name for p in stash_handler.walk_media(tmp_path, EXTS)}
|
||||
assert found == {"a.mp4", "b.mkv"}
|
||||
|
||||
|
||||
def test_transfer_copy_preserves_original(tmp_path):
|
||||
src = tmp_path / "src.mp4"
|
||||
src.write_bytes(b"data")
|
||||
dst = tmp_path / "out" / "src.mp4"
|
||||
stash_handler.transfer_file(src, dst, "copy")
|
||||
assert dst.read_bytes() == b"data"
|
||||
assert src.exists() # seeding can continue
|
||||
|
||||
|
||||
def test_transfer_hardlink_preserves_original(tmp_path):
|
||||
src = tmp_path / "src.mp4"
|
||||
src.write_bytes(b"data")
|
||||
dst = tmp_path / "out" / "src.mp4"
|
||||
stash_handler.transfer_file(src, dst, "hardlink")
|
||||
assert dst.read_bytes() == b"data"
|
||||
assert src.exists()
|
||||
|
||||
|
||||
def test_transfer_move_removes_original(tmp_path):
|
||||
src = tmp_path / "src.mp4"
|
||||
src.write_bytes(b"data")
|
||||
dst = tmp_path / "out" / "src.mp4"
|
||||
stash_handler.transfer_file(src, dst, "move")
|
||||
assert dst.read_bytes() == b"data"
|
||||
assert not src.exists()
|
||||
Reference in New Issue
Block a user