db: extend schema for categories, channel profiles, and error logs
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user