from concurrent.futures import ThreadPoolExecutor from threading import Barrier import pytest from django.db import connection, connections from django.db.migrations.executor import MigrationExecutor from apps.accounts.models import User from apps.messaging.models import Message from apps.notifications.dispatch_opening import ensure_opening_template from apps.notifications.models import NotificationJob from apps.tickets.dispatch_responses import OPENING_EVENT, open_dispatch_responses, render_opening_sms from apps.tickets.models import DispatchResponseControl, GroupDispatch from apps.tickets.services import reply_ticket from tests.test_dispatch_response_opening import opening_case pytestmark = pytest.mark.django_db(transaction=True) def _postgres_only(): if connection.vendor != "postgresql": pytest.skip("Real row-lock concurrency requires PostgreSQL") def test_two_simultaneous_openings_commit_one_event_and_one_outbox_job(opening_case): _postgres_only() c = opening_case ensure_opening_template() text = render_opening_sms(c["dispatch"]) barrier = Barrier(2) def open_once(user_id): connections.close_all() try: user = User.objects.get(pk=user_id) dispatch = GroupDispatch.objects.get(pk=c["dispatch"].pk) barrier.wait(timeout=10) return open_dispatch_responses(dispatch, user, text)[1] finally: connections.close_all() with ThreadPoolExecutor(max_workers=2) as executor: futures = [executor.submit(open_once, c[actor].pk) for actor in ("staff", "admin")] assert sorted(future.result(timeout=20) for future in futures) == [False, True] assert NotificationJob.objects.filter(event_type=OPENING_EVENT).count() == 1 assert DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_at def test_reply_racing_opening_matches_the_atomic_recipient_snapshot(opening_case): _postgres_only() c = opening_case ensure_opening_template() text = render_opening_sms(c["dispatch"]) barrier = Barrier(2) def run(action): connections.close_all() try: barrier.wait(timeout=10) if action == "reply": return reply_ticket(ticket_id=c["tickets"][1].pk, user=User.objects.get(pk=c["customers"][1].pk), body="پاسخ هم‌زمان") return open_dispatch_responses(c["dispatch"], User.objects.get(pk=c["staff"].pk), text) finally: connections.close_all() with ThreadPoolExecutor(max_workers=2) as executor: futures = [executor.submit(run, action) for action in ("reply", "open")] for future in futures: future.result(timeout=20) control = DispatchResponseControl.objects.get(dispatch=c["dispatch"]) expected = set(Message.objects.filter(ticket__group_dispatch=c["dispatch"], author_id__in=[u.pk for u in c["customers"]], created_at__lte=control.opened_at).values_list("author_id", flat=True)) assert set(control.respondents_snapshot) == {str(value) for value in User.objects.filter(pk__in=expected).values_list("public_id", flat=True)} assert set(NotificationJob.objects.filter(event_type=OPENING_EVENT).values_list("customer_id", flat=True)) == expected def test_additive_migrations_preserve_existing_rows_and_legacy_response_visibility(opening_case): c = opening_case # Turn this fixture into a pre-feature dispatch before moving the test schema back. DispatchResponseControl.objects.filter(dispatch=c["dispatch"]).delete() before_dispatch = list(GroupDispatch.objects.values()) before_messages = list(Message.objects.values()) before_users = list(User.objects.values()) old_targets = [("tickets", "0017_ticketaccessgrant_first_viewed_at_and_more"), ("notifications", "0020_receipt_rejection_template")] executor = MigrationExecutor(connection) current_targets = executor.loader.graph.leaf_nodes() try: executor.migrate(old_targets) MigrationExecutor(connection).migrate(current_targets) assert list(GroupDispatch.objects.values()) == before_dispatch assert list(Message.objects.values()) == before_messages assert list(User.objects.values()) == before_users assert not DispatchResponseControl.objects.filter(dispatch=c["dispatch"]).exists() from apps.tickets.staff_conversation import visible_messages assert visible_messages(c["tickets"][0], c["staff"]).filter(pk=c["message"].pk).exists() finally: MigrationExecutor(connection).migrate(current_targets) def test_migration_refuses_to_drop_response_confidentiality_after_use(opening_case): from django.apps import apps from django.db.migrations.exceptions import IrreversibleError from types import SimpleNamespace import importlib guard = importlib.import_module("apps.tickets.migrations.0018_dispatch_response_opening").preserve_used_response_controls with pytest.raises(IrreversibleError): guard(apps, SimpleNamespace(connection=connection)) assert DispatchResponseControl.objects.filter(dispatch=opening_case["dispatch"]).exists()