test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
8e3a4d95da
commit
164f5f9615
97 files changed
+37281
No files matched your search
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.database import get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.user import User
|
||||
from app.models.notification import Notification, NotificationType
|
||||
from app.models.notification_settings import UserNotificationSettings
|
||||
from app.models.role import Role
|
||||
from app.models.organization import Organization
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from app.api import notification as notification_router
|
||||
from app.core.auth import get_current_user
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
notification_router.router,
|
||||
prefix="/api/notifications",
|
||||
tags=["notifications"]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db():
|
||||
"""Create a fresh database for each test."""
|
||||
# Drop all tables first
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
# Create tables except enterprise ones
|
||||
create_tables()
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
@pytest.fixture
|
||||
def test_role(db) -> Role:
|
||||
"""Create a test role"""
|
||||
role = Role(
|
||||
id=1,
|
||||
name="Test Role",
|
||||
description="Test Role Description",
|
||||
is_default=True
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
@pytest.fixture
|
||||
def test_organization(db) -> Organization:
|
||||
"""Create a test organization"""
|
||||
org = Organization(
|
||||
name="Test Organization",
|
||||
domain="test.com",
|
||||
timezone="UTC"
|
||||
)
|
||||
db.add(org)
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
return org
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db, test_role, test_organization) -> User:
|
||||
"""Create a test user"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="test@example.com",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
full_name="Test User",
|
||||
role_id=test_role.id
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_notifications(db, test_user) -> list[Notification]:
|
||||
"""Create test notifications"""
|
||||
notifications = []
|
||||
for i in range(3):
|
||||
notification = Notification(
|
||||
user_id=test_user.id,
|
||||
type=NotificationType.CHAT,
|
||||
title=f"Test Notification {i+1}",
|
||||
message=f"Test message {i+1}",
|
||||
is_read=i == 0, # First notification is read
|
||||
notification_metadata={"test": True},
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
notifications.append(notification)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
for n in notifications:
|
||||
db.refresh(n)
|
||||
return notifications
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_user) -> TestClient:
|
||||
"""Create test client with mocked dependencies"""
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
app.dependency_overrides[get_db] = lambda: TestingSessionLocal()
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
def test_list_notifications(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""Test listing user's notifications"""
|
||||
response = client.get("/api/notifications")
|
||||
assert response.status_code == 200
|
||||
notifications = response.json()
|
||||
assert len(notifications) == 3
|
||||
assert notifications[0]["user_id"] == str(test_user.id)
|
||||
assert notifications[0]["type"] == NotificationType.CHAT.value
|
||||
|
||||
def test_list_notifications_pagination(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""Test notification listing with pagination"""
|
||||
response = client.get("/api/notifications?skip=1&limit=1")
|
||||
assert response.status_code == 200
|
||||
notifications = response.json()
|
||||
assert len(notifications) == 1
|
||||
|
||||
def test_mark_as_read(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""Test marking a notification as read"""
|
||||
unread_notification = next(n for n in test_notifications if not n.is_read)
|
||||
response = client.patch(f"/api/notifications/{unread_notification.id}/read")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Notification marked as read"
|
||||
|
||||
# Verify notification is marked as read in database
|
||||
db.refresh(unread_notification) # Refresh from database
|
||||
assert unread_notification.is_read == True
|
||||
|
||||
def test_mark_as_read_not_found(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Test marking non-existent notification as read"""
|
||||
response = client.patch("/api/notifications/999/read")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Notification not found"
|
||||
|
||||
def test_mark_as_read_wrong_user(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Test marking another user's notification as read"""
|
||||
# Create another user
|
||||
other_user = User(
|
||||
email="other@example.com",
|
||||
full_name="Other User",
|
||||
hashed_password="hashed_password",
|
||||
organization_id=test_user.organization_id # Use same org as test_user
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
|
||||
# Create notification for different user
|
||||
other_notification = Notification(
|
||||
user_id=other_user.id, # Use the other user's ID
|
||||
type="CHAT",
|
||||
title="Other Notification",
|
||||
message="Test message",
|
||||
notification_metadata={"test": True}
|
||||
)
|
||||
db.add(other_notification)
|
||||
db.commit()
|
||||
|
||||
response = client.patch(f"/api/notifications/{other_notification.id}/read")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Notification not found"
|
||||
|
||||
def test_get_unread_count(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""Test getting unread notification count"""
|
||||
response = client.get("/api/notifications/unread-count")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 2 # Two unread notifications from fixture
|
||||
|
||||
def test_get_or_create_survives_insert_race(db, test_user):
|
||||
"""A concurrent first-insert (IntegrityError) re-reads instead of 500ing"""
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.repositories.notification_settings import UserNotificationSettingsRepository
|
||||
|
||||
repo = UserNotificationSettingsRepository(db)
|
||||
|
||||
# The row the winning racer inserted; our repo's first get() is forced to
|
||||
# miss it (as if the winner hadn't committed yet when we looked).
|
||||
winner_row = UserNotificationSettings(user_id=test_user.id)
|
||||
db.add(winner_row)
|
||||
db.commit()
|
||||
|
||||
real_get = repo.get
|
||||
calls = {"n": 0}
|
||||
|
||||
def get_missing_first(user_id):
|
||||
calls["n"] += 1
|
||||
return None if calls["n"] == 1 else real_get(user_id)
|
||||
|
||||
with patch.object(repo, "get", side_effect=get_missing_first), \
|
||||
patch.object(db, "commit", side_effect=IntegrityError("dup", {}, Exception())):
|
||||
result = repo.get_or_create(test_user.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.user_id == test_user.id
|
||||
|
||||
|
||||
def test_get_settings_creates_defaults(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""First read lazily creates the row with the shipped defaults"""
|
||||
response = client.get("/api/notifications/settings")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"notify_new_chat": False,
|
||||
"notify_chat_transfer": True,
|
||||
"notify_chat_assigned": True,
|
||||
}
|
||||
|
||||
settings = db.query(UserNotificationSettings)\
|
||||
.filter_by(user_id=test_user.id).first()
|
||||
assert settings is not None
|
||||
|
||||
|
||||
def test_update_settings_is_partial(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Only the toggles sent are changed; the rest keep their values"""
|
||||
response = client.put(
|
||||
"/api/notifications/settings",
|
||||
json={"notify_new_chat": True}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["notify_new_chat"] is True
|
||||
assert body["notify_chat_transfer"] is True
|
||||
assert body["notify_chat_assigned"] is True
|
||||
|
||||
response = client.put(
|
||||
"/api/notifications/settings",
|
||||
json={"notify_chat_transfer": False}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["notify_new_chat"] is True
|
||||
assert body["notify_chat_transfer"] is False
|
||||
|
||||
|
||||
def test_settings_are_per_user(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Updating the caller's settings never touches another user's row"""
|
||||
other_user = User(
|
||||
id=uuid4(),
|
||||
email="other-settings@example.com",
|
||||
full_name="Other User",
|
||||
hashed_password="hashed_password",
|
||||
organization_id=test_user.organization_id
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
|
||||
other_settings = UserNotificationSettings(user_id=other_user.id)
|
||||
db.add(other_settings)
|
||||
db.commit()
|
||||
|
||||
response = client.put(
|
||||
"/api/notifications/settings",
|
||||
json={"notify_new_chat": True, "notify_chat_assigned": False}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
db.refresh(other_settings)
|
||||
assert other_settings.notify_new_chat is False
|
||||
assert other_settings.notify_chat_assigned is True
|
||||
|
||||
|
||||
def test_send_test_notification(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Test sending a test notification"""
|
||||
response = client.post("/api/notifications/test")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Test notification sent successfully"
|
||||
|
||||
# Verify notification was created
|
||||
notification = db.query(Notification)\
|
||||
.filter_by(user_id=test_user.id)\
|
||||
.order_by(Notification.created_at.desc())\
|
||||
.first()
|
||||
assert notification is not None
|
||||
assert notification.title == "Test Notification"
|
||||
assert notification.type == NotificationType.CHAT
|
||||
|
||||
def test_mark_all_as_read_clears_every_unread(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""One request must clear the whole backlog, not just the fetched page.
|
||||
|
||||
The client used to loop the 50 rows it had loaded, so anyone with more unread
|
||||
than that could never clear the badge — it sat at 99+ no matter how often
|
||||
they tapped "Mark all read".
|
||||
"""
|
||||
response = client.post("/api/notifications/read-all")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["updated"] == 2 # the fixture leaves two unread
|
||||
|
||||
for n in test_notifications:
|
||||
db.refresh(n)
|
||||
assert n.is_read is True
|
||||
|
||||
|
||||
def test_mark_all_as_read_leaves_other_users_alone(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
"""A bulk UPDATE is exactly where a missing user filter goes unnoticed."""
|
||||
other_user = User(
|
||||
email="bulk-other@example.com",
|
||||
hashed_password="x",
|
||||
full_name="Other",
|
||||
organization_id=test_user.organization_id,
|
||||
role_id=test_user.role_id,
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
theirs = Notification(
|
||||
user_id=other_user.id,
|
||||
type=NotificationType.CHAT,
|
||||
title="Theirs",
|
||||
message="Theirs",
|
||||
is_read=False,
|
||||
)
|
||||
db.add(theirs)
|
||||
db.commit()
|
||||
|
||||
client.post("/api/notifications/read-all")
|
||||
|
||||
db.refresh(theirs)
|
||||
assert theirs.is_read is False
|
||||
|
||||
|
||||
def test_delete_notification(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
target = test_notifications[0]
|
||||
response = client.delete(f"/api/notifications/{target.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert db.query(Notification).filter(Notification.id == target.id).first() is None
|
||||
# The others survive
|
||||
assert db.query(Notification).filter(
|
||||
Notification.user_id == test_user.id
|
||||
).count() == len(test_notifications) - 1
|
||||
|
||||
|
||||
def test_delete_notification_not_found(client, db, test_user):
|
||||
response = client.delete("/api/notifications/999999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_another_users_notification_is_not_found(
|
||||
client,
|
||||
db,
|
||||
test_user
|
||||
):
|
||||
"""Must 404 rather than delete someone else's row."""
|
||||
other_user = User(
|
||||
email="delete-other@example.com",
|
||||
hashed_password="x",
|
||||
full_name="Other",
|
||||
organization_id=test_user.organization_id,
|
||||
role_id=test_user.role_id,
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
theirs = Notification(
|
||||
user_id=other_user.id,
|
||||
type=NotificationType.CHAT,
|
||||
title="Theirs",
|
||||
message="Theirs",
|
||||
is_read=False,
|
||||
)
|
||||
db.add(theirs)
|
||||
db.commit()
|
||||
db.refresh(theirs)
|
||||
|
||||
response = client.delete(f"/api/notifications/{theirs.id}")
|
||||
assert response.status_code == 404
|
||||
assert db.query(Notification).filter(Notification.id == theirs.id).first() is not None
|
||||
|
||||
|
||||
def test_clear_all_notifications(
|
||||
client,
|
||||
db,
|
||||
test_user,
|
||||
test_notifications
|
||||
):
|
||||
response = client.delete("/api/notifications")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted"] == len(test_notifications)
|
||||
|
||||
assert db.query(Notification).filter(
|
||||
Notification.user_id == test_user.id
|
||||
).count() == 0
|
||||
Reference in new issue
Block a user