mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
A photo sent to the chat no longer enters the album by itself. pack_add strips it, checks it against the album and the queue, and writes it to pending/ with a sidecar naming the people and labels the sender proposed; the reply is "czeka na zatwierdzenie". The management page lists the queue above the tagger, with the proposal pre-ticked, and Approve moves the file in, mints the labels, records approved_at and asks the bot to rebuild its table through /_reload -- the same publish path every other data change takes, so PackHolder.reload is gone. A reload the bot refuses is reported on the page, not rolled back: the photo is in the album either way. The personal-data gate now reads pending/*.json too, so a label that is still waiting for approval is forbidden in the tree already. The e2e run boots the management page beside the bot with the same environment, proves the queue is stripped and never reaches media/, approves it through the real routes, and has !losowe answer with the photo afterwards without a restart; exiftool is found through media_strip.locate() there rather than PATH, which lacks vendor_perl on Arch. !gadaj is dropped from !odcisz: it was within a typo of !dodaj, and the nearest-command suggestion must never be a coin toss. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
6.9 KiB
Python
180 lines
6.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The approval queue: in, listed, out -- and into the album only whole."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import stat
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from signal_bot.groups import ConfigError
|
|
from signal_bot.pack import MEDIA_DIR, PEOPLE_FILE, TAGS_FILE
|
|
from signal_bot.pack_custom import CUSTOM_FILE
|
|
from signal_bot.pack_load import load
|
|
from signal_bot.pack_pending import (
|
|
PENDING_DIR,
|
|
Pending,
|
|
approve,
|
|
discard,
|
|
listing,
|
|
names,
|
|
queue,
|
|
)
|
|
from signal_bot.pack_store import ADDS_FILE
|
|
from tests import pack_fixture
|
|
from tests.conftest import WATCHED_GROUP
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
ONE = Pending("one.jpg", ("p1",), ("wakacje",), "uuid-alice", 100, WATCHED_GROUP)
|
|
TWO = Pending("two.jpg", (), ("gory", "morze"), "uuid-bob", 50, WATCHED_GROUP)
|
|
BODY = b"\xff\xd8\xff\xe0one"
|
|
|
|
|
|
def _album(tmp_path: Path) -> Path:
|
|
root = pack_fixture.build(tmp_path / "pack")
|
|
queue(root, BODY, ONE)
|
|
queue(root, b"\xff\xd8\xff\xe0two", TWO)
|
|
return root
|
|
|
|
|
|
def test_queued_entries_come_back_oldest_first_with_their_bytes(tmp_path: Path) -> None:
|
|
root = _album(tmp_path)
|
|
assert listing(root) == [TWO, ONE]
|
|
assert names(root) == frozenset({"one.jpg", "two.jpg"})
|
|
assert (root / PENDING_DIR / "one.jpg").read_bytes() == BODY
|
|
for path in (root / PENDING_DIR).iterdir():
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
|
|
|
|
def test_an_album_with_no_queue_yet_has_nothing_waiting(tmp_path: Path) -> None:
|
|
root = pack_fixture.build(tmp_path / "pack")
|
|
assert listing(root) == []
|
|
assert names(root) == frozenset()
|
|
|
|
|
|
def test_a_broken_or_orphaned_sidecar_is_skipped_with_a_warning(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""The page must still show the other photos."""
|
|
root = _album(tmp_path)
|
|
(root / PENDING_DIR / "broken.jpg.json").write_text("{not json", encoding="utf-8")
|
|
(root / PENDING_DIR / "short.jpg.json").write_text('{"name": "short.jpg"}', encoding="utf-8")
|
|
(root / PENDING_DIR / "two.jpg").unlink()
|
|
# A sidecar whose name is not its own filename could point the page anywhere.
|
|
escaped = json.dumps(
|
|
{**json.loads((root / PENDING_DIR / "one.jpg.json").read_text()), "name": "../people.json"}
|
|
)
|
|
(root / PENDING_DIR / "escaped.jpg.json").write_text(escaped, encoding="utf-8")
|
|
with caplog.at_level(logging.WARNING, logger="signal_bot.pack_pending"):
|
|
assert listing(root) == [ONE]
|
|
assert caplog.text.count("skipping unreadable or orphaned queue entry") == 4
|
|
|
|
|
|
def test_discard_removes_both_files_and_tolerates_absence(tmp_path: Path) -> None:
|
|
root = _album(tmp_path)
|
|
discard(root, "one.jpg")
|
|
discard(root, "never-queued.jpg")
|
|
assert listing(root) == [TWO]
|
|
assert not (root / PENDING_DIR / "one.jpg.json").exists()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Approval
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_approving_moves_the_file_into_the_album_and_records_who_sent_it(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
root = _album(tmp_path)
|
|
assert approve(root, "one.jpg", ("p1",), (), now=200) == ("p1",)
|
|
assert (root / MEDIA_DIR / "one.jpg").read_bytes() == BODY
|
|
assert json.loads((root / TAGS_FILE).read_text(encoding="utf-8"))["one.jpg"] == ["p1"]
|
|
adds = json.loads((root / ADDS_FILE).read_text(encoding="utf-8"))
|
|
assert adds["one.jpg"] == {"by": "uuid-alice", "at": 100, "approved_at": 200}
|
|
assert listing(root) == [TWO]
|
|
assert "one.jpg" in load(root).matching(frozenset({"p1"}))
|
|
|
|
|
|
def test_a_proposed_label_is_minted_on_approval_and_reused_after(tmp_path: Path) -> None:
|
|
root = _album(tmp_path)
|
|
assert approve(root, "one.jpg", ("p1",), ("wakacje",), now=200) == ("p1", "t1")
|
|
assert json.loads((root / CUSTOM_FILE).read_text(encoding="utf-8")) == {"t1": ["wakacje"]}
|
|
|
|
queue(root, b"\xff\xd8\xff\xe0three", Pending("three.jpg", (), ("wakacje",), "u", 1, "g"))
|
|
assert approve(root, "three.jpg", (), ("wakacje",), now=201) == ("t1",)
|
|
assert load(root).matching(frozenset({"t1"})) == ("one.jpg", "three.jpg")
|
|
|
|
|
|
def test_the_approver_may_keep_fewer_labels_than_were_proposed(tmp_path: Path) -> None:
|
|
root = _album(tmp_path)
|
|
assert approve(root, "two.jpg", (), ("morze",), now=200) == ("t1",)
|
|
assert json.loads((root / CUSTOM_FILE).read_text(encoding="utf-8")) == {"t1": ["morze"]}
|
|
|
|
|
|
def test_a_label_nobody_proposed_is_refused_before_anything_is_written(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
root = _album(tmp_path)
|
|
with pytest.raises(ValueError, match="not proposed"):
|
|
approve(root, "one.jpg", ("p1",), ("wakacje", "wymyslona"), now=200)
|
|
assert not (root / MEDIA_DIR / "one.jpg").exists()
|
|
assert not (root / CUSTOM_FILE).exists()
|
|
assert listing(root) == [TWO, ONE]
|
|
|
|
|
|
def test_a_name_that_is_not_waiting_is_a_key_error(tmp_path: Path) -> None:
|
|
root = _album(tmp_path)
|
|
with pytest.raises(KeyError, match="not waiting"):
|
|
approve(root, "ghost.jpg", (), (), now=200)
|
|
|
|
|
|
def test_an_album_that_no_longer_loads_after_the_write_is_rolled_back(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A key the album has no person for: written, unloadable, undone -- and still queued."""
|
|
root = _album(tmp_path)
|
|
tags_before = (root / TAGS_FILE).read_bytes()
|
|
with pytest.raises(ConfigError):
|
|
approve(root, "one.jpg", ("p99",), (), now=200)
|
|
assert not (root / MEDIA_DIR / "one.jpg").exists()
|
|
assert (root / TAGS_FILE).read_bytes() == tags_before
|
|
assert not (root / CUSTOM_FILE).exists()
|
|
assert not (root / ADDS_FILE).exists()
|
|
assert listing(root) == [TWO, ONE]
|
|
|
|
|
|
def test_an_album_that_does_not_load_to_begin_with_writes_nothing(tmp_path: Path) -> None:
|
|
root = _album(tmp_path)
|
|
(root / PEOPLE_FILE).write_text("{broken", encoding="utf-8")
|
|
with pytest.raises(ConfigError):
|
|
approve(root, "one.jpg", ("p1",), (), now=200)
|
|
assert not (root / MEDIA_DIR / "one.jpg").exists()
|
|
assert listing(root) == [TWO, ONE]
|
|
|
|
|
|
def test_rows_the_loader_drops_survive_an_approval(tmp_path: Path) -> None:
|
|
"""tags.json is edited as a document: the tagger's "never opened" rows must stay."""
|
|
root = _album(tmp_path)
|
|
document = json.loads((root / TAGS_FILE).read_text(encoding="utf-8"))
|
|
document["alfa.png"] = []
|
|
(root / TAGS_FILE).write_text(json.dumps(document), encoding="utf-8")
|
|
approve(root, "one.jpg", ("p1",), (), now=200)
|
|
after = json.loads((root / TAGS_FILE).read_text(encoding="utf-8"))
|
|
assert after["alfa.png"] == []
|
|
assert after["one.jpg"] == ["p1"]
|
|
|
|
|
|
def test_an_album_the_tagger_has_not_reached_gets_its_first_tags_file(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
root = pack_fixture.build(tmp_path / "pack", write_tags=False)
|
|
queue(root, BODY, ONE)
|
|
approve(root, "one.jpg", ("p1",), (), now=200)
|
|
assert json.loads((root / TAGS_FILE).read_text(encoding="utf-8")) == {"one.jpg": ["p1"]}
|