db: extend schema for categories, channel profiles, and error logs

This commit is contained in:
mamad
2026-08-28 19:33:15 +03:30
parent 8eb5ca0a3f
commit db2e726a57
5 changed files with 889 additions and 23 deletions
+124
View File
@@ -0,0 +1,124 @@
import asyncio
import sys
sys.path.insert(0, "/app")
from db.database import init_db, close_db_pool
from db.repository import Repository
from db.models import ChannelCategory, SourceChannel, TargetChannel
from services.admin_bot import AdminBotService
SRC_1 = -1009999000031
SRC_2 = -1009999000032
TRG_1 = -1009999000033
TRG_2 = -1009999000034
async def _cleanup(repo):
pool = await repo._get_pool()
async with pool.acquire() as conn:
for cid in (SRC_1, SRC_2):
await conn.execute("DELETE FROM sources WHERE channel_id = $1;", cid)
for cid in (TRG_1, TRG_2):
await conn.execute("DELETE FROM targets WHERE channel_id = $1;", cid)
await conn.execute("DELETE FROM channel_categories WHERE name LIKE 'Test Cat%';")
async def run_tests():
await init_db()
repo = Repository()
await _cleanup(repo)
# 1. Create categories
cat1_id = await repo.create_category("Test Cat Tech", "both", "Technology news")
cat2_id = await repo.create_category("Test Cat Crypto", "both", "Crypto & Finance")
assert cat1_id > 0
assert cat2_id > 0
cats = await repo.get_categories()
cat_names = [c.name for c in cats]
assert "Test Cat Tech" in cat_names
assert "Test Cat Crypto" in cat_names
# 2. Add Sources & Targets
s1_id = await repo.add_source(SRC_1, "Tech Source 1", "tech_src1")
s2_id = await repo.add_source(SRC_2, "Crypto Source 2", "crypto_src2")
t1_id = await repo.add_target(TRG_1, "Tech Target 1", "tech_trg1")
t2_id = await repo.add_target(TRG_2, "Crypto Target 2", "crypto_trg2")
# Initial state: no category
assert (await repo.get_source_by_id(s1_id)).category_id is None
assert (await repo.get_target_by_id(t1_id)).category_id is None
# 3. Assign categories
await repo.set_source_category(s1_id, cat1_id)
await repo.set_source_category(s2_id, cat2_id)
await repo.set_target_category(t1_id, cat1_id)
await repo.set_target_category(t2_id, cat2_id)
# Verify assignments
assert (await repo.get_source_by_id(s1_id)).category_id == cat1_id
assert (await repo.get_source_by_id(s2_id)).category_id == cat2_id
assert (await repo.get_target_by_id(t1_id)).category_id == cat1_id
assert (await repo.get_target_by_id(t2_id)).category_id == cat2_id
# 4. Filter channels by category
tech_sources = await repo.get_sources_by_category(cat1_id)
tech_targets = await repo.get_targets_by_category(cat1_id)
assert len(tech_sources) == 1 and tech_sources[0].id == s1_id
assert len(tech_targets) == 1 and tech_targets[0].id == t1_id
# 5. Test AdminBot category-based rendering
bot = AdminBotService(repo=repo, ai_processor=None, queue=None, review_channel_id=0, admin_user_ids=[1])
src_text, src_buttons = await bot._render_source_list()
assert "دسته‌بندی‌های کانال‌های مبدا" in src_text
btn_data = [btn.data.decode("utf-8") for row in src_buttons for btn in row]
assert f"src_cat_view:{cat1_id}" in btn_data
assert f"src_cat_view:{cat2_id}" in btn_data
# Test clicking a category for sources
cat_src_text, cat_src_buttons = await bot._render_source_channels_in_category(cat1_id)
assert "کانال‌های مبدا در دسته" in cat_src_text
assert "Tech Cat" in cat_src_text or "Test Cat Tech" in cat_src_text
cat_btn_data = [btn.data.decode("utf-8") for row in cat_src_buttons for btn in row]
assert f"src_view:{s1_id}" in cat_btn_data
# Test target category navigation
trg_text, trg_buttons = await bot._render_target_list()
assert "دسته‌بندی‌های کانال‌های مقصد" in trg_text
trg_btn_data = [btn.data.decode("utf-8") for row in trg_buttons for btn in row]
assert f"trg_cat_view:{cat1_id}" in trg_btn_data
cat_trg_text, cat_trg_buttons = await bot._render_target_channels_in_category(cat1_id)
assert "کانال‌های مقصد در دسته" in cat_trg_text
cat_trg_btn_data = [btn.data.decode("utf-8") for row in cat_trg_buttons for btn in row]
assert f"trg_view:{t1_id}" in cat_trg_btn_data
# 6. Counts
counts = await repo.get_category_channel_counts(cat1_id)
assert counts["sources"] == 1
assert counts["targets"] == 1
# 7. Rename category
await repo.update_category(cat1_id, name="Test Cat Tech Updated")
updated_cat = await repo.get_category_by_id(cat1_id)
assert updated_cat.name == "Test Cat Tech Updated"
# 8. Unassign category
await repo.set_source_category(s1_id, None)
assert (await repo.get_source_by_id(s1_id)).category_id is None
# 9. Delete category (must nullify channel references safely)
await repo.delete_category(cat2_id)
assert await repo.get_category_by_id(cat2_id) is None
assert (await repo.get_source_by_id(s2_id)).category_id is None
assert (await repo.get_target_by_id(t2_id)).category_id is None
await _cleanup(repo)
await close_db_pool()
print("All category database, repository, and UI rendering tests passed successfully!")
if __name__ == "__main__":
asyncio.run(run_tests())
+78
View File
@@ -0,0 +1,78 @@
"""Integration tests for the repository layer. Requires a reachable Postgres."""
import asyncio
import sys
sys.path.insert(0, "/app")
from db.database import init_db, close_db_pool
from db.repository import Repository
TEST_SOURCE_ID = -1009999000001
TEST_TARGET_ID = -1009999000002
# Deliberately hostile title: quotes and a backslash must survive the JSONB round-trip.
TEST_TARGET_TITLE = 'News "Daily" \\ Channel'
async def _cleanup(repo: Repository):
pool = await repo._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM posts WHERE source_channel_id = $1;", TEST_SOURCE_ID)
await conn.execute("DELETE FROM sources WHERE channel_id = $1;", TEST_SOURCE_ID)
await conn.execute("DELETE FROM targets WHERE channel_id = $1;", TEST_TARGET_ID)
async def run_tests():
await init_db()
repo = Repository()
await _cleanup(repo)
# 1. Sources are looked up by their Telegram channel_id, not the surrogate row id.
await repo.add_source(TEST_SOURCE_ID, "Source Tech", "source_tech")
source = await repo.get_source_by_channel_id(TEST_SOURCE_ID)
assert source is not None, "get_source_by_channel_id returned None for a registered source"
assert source.channel_id == TEST_SOURCE_ID
assert source.title == "Source Tech"
assert await repo.get_source_by_channel_id(-1000000000000) is None
# 2. Deduplication lookup by content hash.
post_id = await repo.create_raw_post(
source_channel_id=TEST_SOURCE_ID,
source_message_id=101,
raw_text="Breaking news: AI update released!",
content_hash="hash_12345",
)
assert post_id is not None
dup = await repo.find_duplicate_post("hash_12345")
assert dup is not None and dup.id == post_id, "find_duplicate_post did not match a stored hash"
assert await repo.find_duplicate_post("no_such_hash") is None
# Re-inserting the same source message is rejected by the unique constraint.
assert await repo.create_raw_post(TEST_SOURCE_ID, 101, "dupe") is None
# 3. Queueing records the target without prematurely marking the post published.
target_id = await repo.add_target(TEST_TARGET_ID, TEST_TARGET_TITLE, "target_chan", post_interval_min=15)
await repo.record_post_queued_to_target(post_id, target_id, TEST_TARGET_TITLE)
post = await repo.get_post_by_id(post_id)
assert post.status == "pending_review", f"queueing must not publish, got {post.status}"
assert len(post.published_to) == 1, f"expected 1 queue entry, got {post.published_to}"
assert post.published_to[0]["target_title"] == TEST_TARGET_TITLE, "title was mangled in JSONB"
assert post.published_to[0]["published_at"] is None
# 4. Publishing flips status and stamps a real timestamp on the existing entry.
await repo.record_post_published_to_target(post_id, target_id, TEST_TARGET_TITLE)
post = await repo.get_post_by_id(post_id)
assert post.status == "published", f"expected published, got {post.status}"
assert len(post.published_to) == 1, f"publishing must not duplicate the entry, got {post.published_to}"
stamped = post.published_to[0]["published_at"]
assert stamped and "class" not in str(stamped), f"published_at is not a timestamp: {stamped!r}"
# 5. Counting by status must not require loading every row.
assert await repo.count_posts_by_status("published") >= 1
await _cleanup(repo)
await close_db_pool()
print("All repository tests passed successfully!")
if __name__ == "__main__":
asyncio.run(run_tests())