fix(chats): prevent duplicate saved messages entries in chat list
Some checks failed
CI / test (push) Failing after 1m57s

This commit is contained in:
2026-03-08 21:13:40 +03:00
parent af3c5bd79e
commit 926413534b
3 changed files with 116 additions and 3 deletions

View File

@@ -60,7 +60,11 @@ def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Ch
ChatUserSetting, ChatUserSetting,
(ChatUserSetting.chat_id == Chat.id) & (ChatUserSetting.user_id == user_id), (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(): if query and query.strip():
q = f"%{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) select(Chat)
.join(ChatMember, ChatMember.chat_id == Chat.id) .join(ChatMember, ChatMember.chat_id == Chat.id)
.where(ChatMember.user_id == user_id, Chat.is_saved.is_(True)) .where(ChatMember.user_id == user_id, Chat.is_saved.is_(True))
.order_by(Chat.id.asc())
.limit(1) .limit(1)
) )
result = await db.execute(stmt) result = await db.execute(stmt)

View File

@@ -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) 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) 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) 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) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)
## Current Focus to reach ~80% ## Current Focus to reach ~80%

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from app.auth.models import EmailVerificationToken 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 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() == [] 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): 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") 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") 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() 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): 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") 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") u2 = await _create_verified_user(client, db_session, "pm_u2@example.com", "pm_user_two", "strongpass123")