diff --git a/app/chats/repository.py b/app/chats/repository.py index 8f2bf15..2e2c901 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -60,7 +60,11 @@ def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Ch ChatUserSetting, (ChatUserSetting.chat_id == Chat.id) & (ChatUserSetting.user_id == user_id), ) - .where(ChatMember.user_id == user_id, func.coalesce(ChatUserSetting.archived, False).is_(False)) + .where( + ChatMember.user_id == user_id, + func.coalesce(ChatUserSetting.archived, False).is_(False), + Chat.is_saved.is_(False), + ) ) if query and query.strip(): q = f"%{query.strip()}%" @@ -162,6 +166,7 @@ async def find_saved_chat_for_user(db: AsyncSession, *, user_id: int) -> Chat | select(Chat) .join(ChatMember, ChatMember.chat_id == Chat.id) .where(ChatMember.user_id == user_id, Chat.is_saved.is_(True)) + .order_by(Chat.id.asc()) .limit(1) ) result = await db.execute(stmt) diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index bb26152..a0f87c5 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -40,7 +40,7 @@ Legend: 31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; group-invite `nobody` is available in API and web settings; integration tests cover PM policy matrix (`everyone/contacts/nobody`), group-invite policy matrix (`everyone/contacts/nobody`), private chat counterpart visibility for `nobody/contacts/everyone`, and avatar visibility matrix in search for `everyone/contacts/nobody`, remaining UX/matrix hardening) 32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; integration tests cover single-session revoke and revoke-all invalidation/force-disconnect; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added and covered for normalization/lifecycle (`remaining_codes` decrement + one-time usage); web auth panel supports recovery-code login; settings now warns when recovery codes are empty and provides copy/download actions for freshly generated codes) 33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted) -34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`, and per-user chat state mutations (archive/unarchive/pin/unpin/mute) now emit `chat_updated`) +34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`, per-user chat state mutations (archive/unarchive/pin/unpin/mute) now emit `chat_updated`, and chat list now excludes duplicate `is_saved` rows from regular listing to keep a single Saved Messages entry) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic) ## Current Focus to reach ~80% diff --git a/tests/test_chat_message_flow.py b/tests/test_chat_message_flow.py index c6dda08..c202c79 100644 --- a/tests/test_chat_message_flow.py +++ b/tests/test_chat_message_flow.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import select from app.auth.models import EmailVerificationToken -from app.chats.models import ChatType +from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType from app.messages.models import Message @@ -135,6 +135,40 @@ async def test_delete_saved_messages_chat_clears_messages_but_keeps_chat(client, assert messages_after_delete.json() == [] +async def test_chat_list_hides_duplicate_saved_chats_and_returns_single_saved_entry(client, db_session): + user = await _create_verified_user( + client, + db_session, + "saved_duplicate_user@example.com", + "saved_duplicate_user", + "strongpass123", + ) + auth = {"Authorization": f"Bearer {user['access_token']}"} + me_response = await client.get("/api/v1/auth/me", headers=auth) + user_id = me_response.json()["id"] + + # Force-create an extra saved chat row to emulate historical duplicate data. + duplicate_saved_chat = Chat( + type=ChatType.PRIVATE, + title="Saved Messages", + description="Personal cloud chat", + is_public=False, + is_saved=True, + ) + db_session.add(duplicate_saved_chat) + await db_session.flush() + db_session.add( + ChatMember(chat_id=duplicate_saved_chat.id, user_id=user_id, role=ChatMemberRole.OWNER) + ) + await db_session.commit() + + chats_response = await client.get("/api/v1/chats", headers=auth) + assert chats_response.status_code == 200 + rows = chats_response.json() + saved_rows = [row for row in rows if row.get("is_saved") is True] + assert len(saved_rows) == 1 + + async def test_chat_list_includes_notification_muted_flag(client, db_session): u1 = await _create_verified_user(client, db_session, "muted_flag_u1@example.com", "muted_flag_u1", "strongpass123") u2 = await _create_verified_user(client, db_session, "muted_flag_u2@example.com", "muted_flag_u2", "strongpass123") @@ -193,6 +227,80 @@ async def test_media_upload_url_accepts_mp4_voice_mime_types(client, db_session) assert "upload_url" in m4a_response.json() +async def test_archive_and_pin_chat_are_user_scoped(client, db_session): + u1 = await _create_verified_user(client, db_session, "scope_u1@example.com", "scope_u1", "strongpass123") + u2 = await _create_verified_user(client, db_session, "scope_u2@example.com", "scope_u2", "strongpass123") + + me_u2 = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {u2['access_token']}"}) + u2_id = me_u2.json()["id"] + + create_chat_response = await client.post( + "/api/v1/chats", + headers={"Authorization": f"Bearer {u1['access_token']}"}, + json={"type": ChatType.PRIVATE.value, "title": None, "member_ids": [u2_id]}, + ) + assert create_chat_response.status_code == 200 + chat_id = create_chat_response.json()["id"] + + pin_response = await client.post( + f"/api/v1/chats/{chat_id}/pin-chat", + headers={"Authorization": f"Bearer {u1['access_token']}"}, + ) + assert pin_response.status_code == 200 + assert pin_response.json()["pinned"] is True + + u1_chats_after_pin = await client.get( + "/api/v1/chats", + headers={"Authorization": f"Bearer {u1['access_token']}"}, + ) + assert u1_chats_after_pin.status_code == 200 + u1_row_after_pin = next((item for item in u1_chats_after_pin.json() if item["id"] == chat_id), None) + assert u1_row_after_pin is not None + assert u1_row_after_pin["pinned"] is True + + u2_chats_after_u1_pin = await client.get( + "/api/v1/chats", + headers={"Authorization": f"Bearer {u2['access_token']}"}, + ) + assert u2_chats_after_u1_pin.status_code == 200 + u2_row_after_u1_pin = next((item for item in u2_chats_after_u1_pin.json() if item["id"] == chat_id), None) + assert u2_row_after_u1_pin is not None + assert u2_row_after_u1_pin["pinned"] is False + + archive_response = await client.post( + f"/api/v1/chats/{chat_id}/archive", + headers={"Authorization": f"Bearer {u1['access_token']}"}, + ) + assert archive_response.status_code == 200 + assert archive_response.json()["archived"] is True + + u1_active_chats = await client.get( + "/api/v1/chats", + headers={"Authorization": f"Bearer {u1['access_token']}"}, + ) + assert u1_active_chats.status_code == 200 + assert all(item["id"] != chat_id for item in u1_active_chats.json()) + + u1_archived_chats = await client.get( + "/api/v1/chats", + params={"archived": True}, + headers={"Authorization": f"Bearer {u1['access_token']}"}, + ) + assert u1_archived_chats.status_code == 200 + u1_archived_row = next((item for item in u1_archived_chats.json() if item["id"] == chat_id), None) + assert u1_archived_row is not None + assert u1_archived_row["archived"] is True + + u2_active_chats = await client.get( + "/api/v1/chats", + headers={"Authorization": f"Bearer {u2['access_token']}"}, + ) + assert u2_active_chats.status_code == 200 + u2_row_after_u1_archive = next((item for item in u2_active_chats.json() if item["id"] == chat_id), None) + assert u2_row_after_u1_archive is not None + assert u2_row_after_u1_archive["archived"] is False + + async def test_private_chat_respects_contacts_only_policy(client, db_session): u1 = await _create_verified_user(client, db_session, "pm_u1@example.com", "pm_user_one", "strongpass123") u2 = await _create_verified_user(client, db_session, "pm_u2@example.com", "pm_user_two", "strongpass123")