from datetime import datetime, timedelta from zoneinfo import ZoneInfo import pytest from app.database import Database from app.database import FamilyJoinError from app.categories import category_label from app.domain import Category, TransactionKind @pytest.mark.asyncio async def test_transaction_lifecycle_and_totals(tmp_path): database = Database(tmp_path / "finance.db") await database.initialize() now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC")) await database.upsert_user( user_id=10, chat_id=10, username="tester", full_name="Test User", timezone="UTC", report_time="21:00", now=now, ) await database.upsert_user( user_id=20, chat_id=20, username=None, full_name="Other User", timezone="UTC", report_time="21:00", now=now, ) expense = await database.add_transaction( user_id=10, amount_kopecks=30_000, description="бензин", kind=TransactionKind.EXPENSE, category=Category.CAR, occurred_at=now, ) await database.add_transaction( user_id=10, amount_kopecks=100_000, description="подработка", kind=TransactionKind.INCOME, category=Category.INCOME, occurred_at=now, ) totals = await database.totals(10, now - timedelta(hours=1), now + timedelta(hours=1)) assert totals.income == 100_000 assert totals.expense == 30_000 assert totals.balance == 70_000 assert await database.category_totals( 10, now - timedelta(hours=1), now + timedelta(hours=1) ) == {category_label(Category.CAR): 30_000} assert await database.category_totals(10) == {category_label(Category.CAR): 30_000} # Другой пользователь не может изменить или удалить чужую запись. assert not await database.update_category(expense.id, 20, Category.OTHER) assert not await database.delete_transaction(expense.id, 20) assert await database.update_category(expense.id, 10, Category.OTHER) assert (await database.get_transaction(expense.id, 10)).category is Category.OTHER assert await database.delete_transaction(expense.id, 10) @pytest.mark.asyncio async def test_daily_report_idempotency(tmp_path): database = Database(tmp_path / "finance.db") await database.initialize() now = datetime(2026, 7, 13, 21, tzinfo=ZoneInfo("UTC")) await database.upsert_user( user_id=10, chat_id=10, username=None, full_name="Test User", timezone="UTC", report_time="21:00", now=now, ) assert not await database.report_was_sent(10, "2026-07-13") await database.mark_report_sent(10, "2026-07-13", now) await database.mark_report_sent(10, "2026-07-13", now) assert await database.report_was_sent(10, "2026-07-13") @pytest.mark.asyncio async def test_family_budget_shares_history_and_new_transactions(tmp_path): database = Database(tmp_path / "finance.db") await database.initialize() now = datetime(2026, 7, 13, 20, tzinfo=ZoneInfo("UTC")) for user_id, name in ((10, "Алексей"), (20, "Мария"), (30, "Посторонний")): await database.upsert_user( user_id=user_id, chat_id=user_id, username=None, full_name=name, timezone="UTC", report_time="21:00", now=now, ) await database.add_transaction( user_id=10, amount_kopecks=30_000, description="бензин", kind=TransactionKind.EXPENSE, category=Category.CAR, occurred_at=now, ) await database.add_transaction( user_id=20, amount_kopecks=10_000, description="ярче", kind=TransactionKind.EXPENSE, category=Category.PRODUCTS, occurred_at=now, ) family = await database.create_family(10, now) request = await database.request_family_join(20, family.invite_code.lower(), now) assert request.owner_chat_id == 10 with pytest.raises(FamilyJoinError, match="обработан"): await database.resolve_family_join( owner_user_id=30, candidate_user_id=20, approve=True, now=now, ) approved, candidate_chat_id, joined = await database.resolve_family_join( owner_user_id=10, candidate_user_id=20, approve=True, now=now, ) assert approved assert candidate_chat_id == 20 assert joined is not None assert joined.household_id == family.household_id assert joined.members == ("Алексей", "Мария") for user_id in (10, 20): totals = await database.totals(user_id) assert totals.expense == 40_000 assert await database.category_totals(user_id) == { category_label(Category.CAR): 30_000, category_label(Category.PRODUCTS): 10_000, } recent = await database.recent_transactions(user_id) assert {item.author_name for item in recent} == {"Алексей", "Мария"} recipients = await database.family_recipients(10) assert [(row["user_id"], row["chat_id"]) for row in recipients] == [(20, 20)] with pytest.raises(FamilyJoinError, match="создатель"): await database.bind_family_chat(20, -100500, "Наш бюджет") bound = await database.bind_family_chat(10, -100500, "Наш бюджет") assert bound.notification_chat_id == -100500 assert await database.family_notification_chats(10) == [20, -100500] assert await database.family_report_target(20) == (family.household_id, -100500) assert not await database.family_report_was_sent(family.household_id, "2026-07-13") await database.mark_family_report_sent(family.household_id, "2026-07-13", now) assert await database.family_report_was_sent(family.household_id, "2026-07-13") with pytest.raises(FamilyJoinError, match="создатель"): await database.set_family_debt(20, 53_071_300, now) await database.set_family_debt(10, 53_071_300, now) salary = await database.add_transaction( user_id=20, amount_kopecks=40_000_000, description="зарплата", kind=TransactionKind.INCOME, category=Category.INCOME, occurred_at=now + timedelta(seconds=1), ) assert await database.family_debt(10) == 13_071_300 purchase = await database.add_transaction( user_id=10, amount_kopecks=2_000_000, description="покупка", kind=TransactionKind.EXPENSE, category=Category.OTHER, occurred_at=now + timedelta(seconds=2), ) assert await database.family_debt(20) == 15_071_300 assert await database.delete_transaction(purchase.id, 10) assert await database.family_debt(10) == 13_071_300 assert salary.author_name == "Мария" categories = await database.budget_categories(10) fuel = next(category for category in categories if category.system_key == "car_fuel") assert fuel.parent_name == "Машина" health = await database.create_budget_category( user_id=20, name="Здоровье", parent_id=None, now=now, ) doctors = await database.create_budget_category( user_id=10, name="Врачи", parent_id=health.id, now=now, ) other = await database.add_transaction( user_id=10, amount_kopecks=50_000, description="консультация", kind=TransactionKind.EXPENSE, category=Category.OTHER, occurred_at=now + timedelta(seconds=3), ) assert await database.update_transaction_budget_category(other.id, 10, doctors.id) moved = await database.get_transaction(other.id, 10) assert moved.parent_category_name == "Здоровье" assert moved.budget_category_name == "Врачи" analytics = await database.category_totals(20) assert analytics["📁 Здоровье → 📁 Врачи"] == 50_000 with pytest.raises(FamilyJoinError, match="два участника"): await database.request_family_join(30, family.invite_code, now) @pytest.mark.asyncio async def test_existing_database_is_migrated_for_family_budget(tmp_path): import aiosqlite path = tmp_path / "old.db" async with aiosqlite.connect(path) as db: await db.executescript( """ CREATE TABLE users ( user_id INTEGER PRIMARY KEY, chat_id INTEGER NOT NULL, username TEXT, full_name TEXT NOT NULL, timezone TEXT NOT NULL, report_time TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, amount_kopecks INTEGER NOT NULL, description TEXT NOT NULL, kind TEXT NOT NULL, category TEXT NOT NULL, occurred_at TEXT NOT NULL, created_at TEXT NOT NULL ); """ ) await db.commit() database = Database(path) await database.initialize() async with aiosqlite.connect(path) as db: cursor = await db.execute("PRAGMA table_info(transactions)") columns = {row[1] for row in await cursor.fetchall()} assert "household_id" in columns async with aiosqlite.connect(path) as db: cursor = await db.execute("PRAGMA table_info(households)") household_columns = {row[1] for row in await cursor.fetchall()} assert {"debt_base_kopecks", "debt_started_at"} <= household_columns