Files
memes_bot/test_vault.py
T
goyban a35abb0541 Add a vault for saving whole forwarded posts
Completes TODO #5. Forwarding a post now asks whether to save it whole
or extract its media; sending media directly is unchanged.

Built on forwarding rather than copying file_ids. The bot already has
the post in its private chat with the owner, so it stores message ids
and forwards them back. forwardMessages takes a list and keeps an album
intact, which is what made the caption-plus-album case tractable; ids
must be strictly ascending, so they are sorted on the way out.

- Vault rows live in their own table and every query is scoped to an
  owner_id, so a guessed id cannot reach someone else's saved post.
  Nothing in the vault is searchable inline, moderated or federated,
  and the separate table makes that structural rather than a flag.
- Albums arrive as separate messages with no completion signal, so they
  are buffered by media_group_id behind a 1s debounce and prompt once.
- The add path now takes a list of items, so extracting an album stores
  every piece under one set of keywords instead of asking repeatedly.
- /vault searches keywords and the original caption, since the point of
  a vault is finding something you never got round to tagging.
- Retrieval failures are reported: a message id is a pointer into the
  chat, not a self-contained file_id, so clearing history breaks it.

Three near-identical keyword collectors are now one helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:01:30 +00:00

177 lines
7.0 KiB
Python

"""Vault tests. Run with: python test_vault.py
The vault stores pointers to messages the bot already holds, so the things
worth pinning down are that albums are grouped into one post, that retrieval
asks Telegram for the right message ids, and that one user can never reach
another user's saved posts.
"""
import asyncio
import datetime
import importlib
import logging
import os
import sys
import tempfile
os.environ.setdefault("BOT_TOKEN", "123456789:AAHtestTokenPlaceholder_ABCDEFGHIJKLMNOP")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from aiogram.types import Chat, Message, PhotoSize, User
logging.disable(logging.CRITICAL)
CHAT = Chat(id=500, type="private")
USER = User(id=111, is_bot=False, first_name="Owner")
NOW = datetime.datetime.now()
def load(path: str, **env):
for key in ("PUBLIC_MODE", "REQUIRED_CHATS", "ALLOWED_USERS", "ADMIN_USERS"):
os.environ.pop(key, None)
os.environ["DB_PATH"] = path
os.environ.update(env)
import db, bot
importlib.reload(db)
B = importlib.reload(bot)
B.db = db
return db, B
def photo_msg(message_id: int, caption: str | None = None, group: str | None = None) -> Message:
photo = PhotoSize(file_id=f"F{message_id}", file_unique_id=f"U{message_id}", width=1, height=1)
return Message(message_id=message_id, date=NOW, chat=CHAT, from_user=USER,
photo=[photo], caption=caption, media_group_id=group)
# ── database ─────────────────────────────────────────────────────────────────
async def test_roundtrip(db):
item_id = await db.add_vault_item(111, 500, [12, 10, 11], "A trip to the sea", ["sea"])
item = await db.get_vault_item(111, item_id)
assert db.unpack_message_ids(item["message_ids"]) == [12, 10, 11]
assert item["caption"] == "A trip to the sea"
print(" a saved post round-trips with its message ids and caption")
async def test_search_by_keyword_and_caption(db):
await db.add_vault_item(111, 500, [20], "Recipe for lasagne", ["food"])
assert len(await db.search_vault(111, "food")) == 1, "keyword search"
assert len(await db.search_vault(111, "lasagne")) == 1, "caption search"
assert len(await db.search_vault(111, "lasagne")) == 1
assert await db.search_vault(111, "nonsense") == []
print(" posts are findable by keyword and by words in the original caption")
async def test_vault_is_private_to_its_owner(db):
mine = await db.add_vault_item(111, 500, [30], "private note", ["secret"])
assert await db.get_vault_item(222, mine) is None, "another user fetched my post"
assert await db.search_vault(222, "secret") == [], "another user searched my vault"
assert await db.delete_vault_item(222, mine) is False, "another user deleted my post"
assert await db.get_vault_item(111, mine) is not None, "owner lost their own post"
print(" one user cannot read, search or delete another user's vault")
async def test_delete(db):
item_id = await db.add_vault_item(111, 500, [40], "", ["bye"])
assert await db.delete_vault_item(111, item_id) is True
assert await db.get_vault_item(111, item_id) is None
assert await db.delete_vault_item(111, item_id) is False
print(" deleting removes the post and is safe to repeat")
# ── album grouping ───────────────────────────────────────────────────────────
async def test_album_becomes_one_post(B):
B.ALBUM_DEBOUNCE = 0.05
B._album_buffers.clear()
B._pending_forwards.clear()
sent: list = []
async def send_message(chat_id, text, **kw):
sent.append(text)
B.bot.send_message = send_message
# Telegram delivers an album as separate messages; only one has the caption.
for msg in (photo_msg(10, caption="beach day", group="g1"),
photo_msg(11, group="g1"),
photo_msg(12, group="g1")):
await B.on_forwarded(msg, None)
await asyncio.sleep(0.3)
assert len(sent) == 1, f"an album should prompt once, got {len(sent)} prompts"
assert len(B._pending_forwards) == 1
record = next(iter(B._pending_forwards.values()))
assert record["message_ids"] == [10, 11, 12]
assert record["caption"] == "beach day", "the one caption in the album was lost"
assert len(record["media"]) == 3, "every item in the album should be extractable"
print(" an album is buffered into one post, keeping its caption and all media")
async def test_single_forward_prompts_immediately(B):
B._album_buffers.clear()
B._pending_forwards.clear()
sent: list = []
async def send_message(chat_id, text, **kw):
sent.append(text)
B.bot.send_message = send_message
await B.on_forwarded(photo_msg(20, caption="just one"), None)
assert len(sent) == 1, "a lone forward should not wait for an album"
record = next(iter(B._pending_forwards.values()))
assert record["message_ids"] == [20]
print(" a single forwarded post prompts straight away")
async def test_pending_prompts_are_bounded(B):
B._pending_forwards.clear()
B.bot.send_message = lambda *a, **kw: asyncio.sleep(0)
for i in range(B.PENDING_FORWARD_LIMIT + 25):
await B.prompt_forward_choice(
{"user_id": 1, "chat_id": 500, "message_ids": [i], "caption": "", "media": []}
)
assert len(B._pending_forwards) <= B.PENDING_FORWARD_LIMIT
print(" abandoned prompts cannot grow without bound")
async def test_retrieval_forwards_ascending_ids(db, B):
item_id = await db.add_vault_item(111, 500, [12, 10, 11], "out of order", ["x"])
forwarded: dict = {}
async def forward_messages(chat_id, from_chat_id, message_ids):
forwarded.update(chat_id=chat_id, from_chat_id=from_chat_id, message_ids=message_ids)
B.bot.forward_messages = forward_messages
class Cb:
data = f"vault:get:{item_id}"
from_user = type("U", (), {"id": 111})()
message = type("M", (), {"chat": CHAT})()
async def answer(self, *a, **kw): pass
await B.on_vault_action(Cb())
# Telegram requires strictly increasing ids, and the album must stay whole.
assert forwarded["message_ids"] == [10, 11, 12], forwarded
assert forwarded["from_chat_id"] == 500
print(" retrieval forwards the whole post with ids in ascending order")
async def main():
with tempfile.TemporaryDirectory() as tmp:
db, B = load(os.path.join(tmp, "vault.db"), ADMIN_USERS="111")
await db.init_db()
for test in (test_roundtrip, test_search_by_keyword_and_caption,
test_vault_is_private_to_its_owner, test_delete):
await test(db)
for test in (test_album_becomes_one_post, test_single_forward_prompts_immediately,
test_pending_prompts_are_bounded):
await test(B)
await test_retrieval_forwards_ascending_ids(db, B)
print("\nAll vault tests passed.")
if __name__ == "__main__":
asyncio.run(main())