fix(chats): prevent duplicate saved messages entries in chat list
Some checks failed
CI / test (push) Failing after 1m57s
Some checks failed
CI / test (push) Failing after 1m57s
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user