Corrigir race na criação da UserJourney ao aceitar termos — Plano de implementação

TLDR: Torna síncrona a criação da UserJourney no signal de aceite de termos e remove a task Celery create_user_journey, que fica órfã.

Spec: .project/docs/specs/20260826142256_journey_creation_race.md Branch: fix/journey-creation-race

Arquitetura: O signal create_user_journey_on_terms_acceptance (apps/audios/signals.py) passa a chamar JourneyService.create_for(instance) diretamente, em vez de create_user_journey.delay(instance.pk). A task Celery correspondente é removida por ficar sem nenhum outro chamador.

Stack: Django signals (post_save), pytest-django, unittest.mock.patch.

Restrições globais

  • CELERY_TASK_ALWAYS_EAGER = True em config/settings/test.py — testes rodam tasks Celery de forma síncrona, então um teste que apenas dispara o signal e verifica o resultado final NÃO reproduz a race em ambiente de teste (ela só existe com broker real). O teste que prova a mudança de comportamento precisa verificar que JourneyService.create_for é chamado diretamente, sem passar por .delay()/task.
  • Nenhum outro chamador de create_user_journey existe no código (confirmado via grep em apps/, config/, routes/) — a task pode ser removida sem quebrar nada além dos próprios testes dela.
  • Testes existentes serão modificados (não apenas criados) — três testes em tests/audios/test_journey_signal.py mockam apps.audios.signals.create_user_journey.delay e precisam ser reescritos para o novo comportamento síncrono; cinco testes em tests/audios/test_journey_task.py testam a task removida e precisam ser deletados. Isso está listado explicitamente abaixo para aprovação antes da execução, conforme regra do projeto de nunca alterar teste existente sem confirmação.

Task 1: Signal síncrono + testes do signal

Files: - Modify: apps/audios/signals.py - Modify: tests/audios/test_journey_signal.py

Interfaces: - Consumes: JourneyService.create_for(user) — já existe em apps/audios/services.py, assinatura create_for(cls, user), sem retorno. - Produces: nada consumido por tasks futuras — o signal passa a ser o único ponto de criação da UserJourney.

Testes existentes que serão reescritos (pedido de confirmação): - test_accepting_terms_dispatches_create_user_journey_task → vira test_accepting_terms_creates_user_journey_synchronously (mocka JourneyService.create_for em vez de create_user_journey.delay) - test_irrelevant_field_update_does_not_dispatch_task → vira test_irrelevant_field_update_does_not_create_journey - test_terms_not_accepted_does_not_dispatch_task → vira test_terms_not_accepted_does_not_create_journey

  • [ ] Step 1: Escrever os testes (o antigo comportamento assíncrono deixa de existir)

Substituir o topo de tests/audios/test_journey_signal.py (linhas 1–67) por:

```python from unittest.mock import patch

import pytest from datetime import date, timedelta

from django.contrib.auth import get_user_model from django.core.cache import cache from django.utils import timezone

from apps.audios.models import Journey, JourneyAudio, JourneyKind, UserJourney from apps.audios.models.audio import Audio from apps.audios.services import AudioService from apps.engagements.models.progress import AudioProgress

def make_journey(launch_days_ago=30, is_active=True, kind=JourneyKind.ONBOARDING): return Journey.objects.create( name=”Jornada Padrão”, kind=kind, launch_date=date.today() - timedelta(days=launch_days_ago), is_active=is_active, )

def make_audio(title=”Audio”): return Audio.objects.create( title=title, description=”desc”, media_file=f”audios/{title.lower().replace(‘ ‘, ‘’)}.mp3”, published_at=date.today(), available_time=(timezone.localtime() - timedelta(hours=1)).time(), is_active=True, file_size=1024, duration=300, )

@pytest.mark.django_db def test_accepting_terms_creates_user_journey_synchronously(): # arrange User = get_user_model() user = User.objects.create(email=”new@test.com”, name=”New User”)

# act
with patch("apps.audios.signals.JourneyService.create_for") as mock_create_for:
    user.accept_terms_at = timezone.now()
    user.save(update_fields=["accept_terms_at"])

# assert
mock_create_for.assert_called_once_with(user)

@pytest.mark.django_db def test_irrelevant_field_update_does_not_create_journey(): # arrange User = get_user_model() user = User.objects.create(email=”nofield@test.com”, name=”No Field”)

# act
with patch("apps.audios.signals.JourneyService.create_for") as mock_create_for:
    user.name = "Updated"
    user.save(update_fields=["name"])

# assert
mock_create_for.assert_not_called()

@pytest.mark.django_db def test_terms_not_accepted_does_not_create_journey(): # arrange User = get_user_model() user = User.objects.create(email=”noterms@test.com”, name=”No Terms”)

# act
with patch("apps.audios.signals.JourneyService.create_for") as mock_create_for:
    user.accept_terms_at = None
    user.save(update_fields=["accept_terms_at"])

# assert
mock_create_for.assert_not_called()

@pytest.mark.django_db def test_accepting_terms_makes_daily_audio_return_journey_first_audio_immediately(free_audio): # arrange — nenhuma task Celery é executada explicitamente; só o save() do signal User = get_user_model() user = User.objects.create(email=”racefix@test.com”, name=”Race Fix”) journey = make_journey() ja_first = JourneyAudio.objects.create(journey=journey, audio=_make_audio(“Journey A1”), position=1)

# act
user.accept_terms_at = timezone.now()
user.save(update_fields=["accept_terms_at"])
fresh_user = User.objects.get(pk=user.pk)
result = AudioService.get_daily_audio(fresh_user)

# assert
assert result == ja_first.audio ```

Manter o restante do arquivo (linhas 70 em diante — testes de UserJourney.advance_to_next, Journey.next_audio etc.) sem alteração.

  • [ ] Step 2: Rodar e confirmar falha

bash make run.test path=tests/audios/test_journey_signal.py

Expected: test_accepting_terms_creates_user_journey_synchronously, test_irrelevant_field_update_does_not_create_journey e test_terms_not_accepted_does_not_create_journey FALHAM com AttributeError: <module 'apps.audios.signals' ...> does not have the attribute 'JourneyService' (o signal ainda não importa JourneyService). test_accepting_terms_makes_daily_audio_return_journey_first_audio_immediately passa mesmo antes do fix — CELERY_TASK_ALWAYS_EAGER=True faz create_user_journey.delay() rodar de forma síncrona em teste, mascarando a race que só existe com broker real; o teste fica como cobertura do fluxo ponta a ponta, não como o RED desta task.

  • [ ] Step 3: Implementar o signal síncrono

Editar apps/audios/signals.py:

```python from django.contrib.auth import get_user_model from django.core.cache import cache from django.db.models.signals import post_save from django.dispatch import receiver

from .models import Audio from .services import JourneyService

@receiver(post_save, sender=Audio) def on_audio_save(sender, instance, **kwargs): # pylint: disable=unused-argument cache.delete(“audio_daily”)

@receiver(post_save, sender=get_user_model()) def create_user_journey_on_terms_acceptance(sender, instance, **kwargs): # pylint: disable=unused-argument update_fields = kwargs.get(“update_fields”) if update_fields is not None and “accept_terms_at” not in update_fields: return if instance.accept_terms_at is None: return JourneyService.create_for(instance) ```

  • [ ] Step 4: Rodar e confirmar sucesso

bash make run.test path=tests/audios/test_journey_signal.py

Expected: PASS (todos os testes do arquivo)

  • [ ] Step 5: Commit

bash git add apps/audios/signals.py tests/audios/test_journey_signal.py git commit -m "fix: create UserJourney synchronously on terms accept"


Task 2: Remover a task Celery órfã

Files: - Modify: apps/audios/tasks.py - Modify: tests/audios/test_journey_task.py

Interfaces: - Consumes: nada — apenas remove código morto. - Produces: nada — advance_user_journey continua existindo e inalterada.

Testes existentes que serão removidos (pedido de confirmação): test_create_user_journey_task_creates_journey_for_user, test_create_user_journey_task_sets_first_audio_as_current, test_create_user_journey_task_is_idempotent, test_create_user_journey_task_does_nothing_when_no_active_journey, test_create_user_journey_task_does_nothing_when_accept_terms_at_is_none — testavam a task create_user_journey, que deixa de existir. O comportamento que elas cobriam (criação idempotente, primeiro áudio, jornada inativa, termos não aceitos) já está coberto por tests/audios/test_journey_service.py::test_create_for_*, que testa JourneyService.create_for diretamente.

  • [ ] Step 1: Escrever o teste (apenas a task que sobrevive)

Substituir todo o conteúdo de tests/audios/test_journey_task.py por:

```python from datetime import date, timedelta from unittest.mock import patch

import pytest from django.contrib.auth import get_user_model from django.utils import timezone

from apps.audios.models.audio import Audio from apps.audios.tasks import advance_user_journey from apps.engagements.models.progress import AudioProgress

def make_audio(title=”Audio Task”): return Audio.objects.create( title=title, description=”desc”, media_file=f”audios/{title.lower().replace(‘ ‘, ‘’)}.mp3”, published_at=date.today(), available_time=(timezone.localtime() - timedelta(hours=1)).time(), is_active=True, file_size=1024, duration=300, )

@pytest.mark.django_db def test_advance_user_journey_task_delegates_to_service(): # arrange User = get_user_model() user = User.objects.create(email=”adv_delegate@test.com”, name=”Delegate”) audio = _make_audio(“Delegate Audio”) ap = AudioProgress.objects.create(user=user, audio=audio, position=audio.duration, completed_at=timezone.now())

# act
with patch("apps.audios.tasks.JourneyService.advance_for") as mock_advance:
    advance_user_journey(ap.pk)

# assert
mock_advance.assert_called_once_with(ap) ```
  • [ ] Step 2: Rodar e confirmar falha

bash make run.test path=tests/audios/test_journey_task.py

Expected: PASS já nesse ponto (o teste que resta não depende da task removida) — a falha real desta task aparece no Step 4, ao rodar o arquivo inteiro do app depois de remover create_user_journey de apps/audios/tasks.py: se algum import remanescente de create_user_journey sobrar em outro arquivo, o teste falha por ImportError.

  • [ ] Step 3: Remover a task de apps/audios/tasks.py

Remover o bloco (linhas 27–31 do arquivo atual):

python @app.task(base=BaseTaskWithRetry, queue=resolve_queue("default")) def create_user_journey(user_id: int): user = get_user_model().objects.get(pk=user_id) JourneyService.create_for(user) logger.info("create_user_journey | user_id=%s", user_id)

O import from apps.audios.services import JourneyService no topo de apps/audios/tasks.py continua necessário — advance_user_journey ainda usa JourneyService.advance_for.

  • [ ] Step 4: Rodar e confirmar sucesso

bash make run.test path=tests/audios

Expected: PASS (nenhum ImportError, todo o app audios verde)

  • [ ] Step 5: Commit

bash git add apps/audios/tasks.py tests/audios/test_journey_task.py git commit -m "fix: remove orphaned create_user_journey task"


Task 3: Atualizar documentação de referência

Files: - Modify: .project/docs/reference/audios/daily_audio_journey.md

Interfaces: - Consumes: nada. - Produces: nada — apenas texto.

  • [ ] Step 1: Atualizar o parágrafo sobre criação da UserJourney

Em .project/docs/reference/audios/daily_audio_journey.md, na seção “Visão geral”, substituir a frase:

A distinção é feita comparando `User.accept_terms_at` com `Journey.launch_date`. O `UserJourney` é criado no momento em que o usuário aceita os termos (`accept_terms_at`). A jornada ativa com `launch_date` mais recente é selecionada.

por:

A distinção é feita comparando `User.accept_terms_at` com `Journey.launch_date`. O `UserJourney` é criado de forma síncrona, dentro do mesmo `post_save` do aceite de termos (`apps/audios/signals.py`, `create_user_journey_on_terms_acceptance` → `JourneyService.create_for`) — não passa por uma task Celery, justamente para evitar que uma leitura de `GET /audios/daily` logo após o aceite dos termos aconteça antes da `UserJourney` existir. A jornada ativa com `launch_date` mais recente é selecionada.

  • [ ] Step 2: Commit

bash git add .project/docs/reference/audios/daily_audio_journey.md git commit -m "docs: clarify UserJourney creation is synchronous"