Corrigir race na criação da UserJourney ao aceitar termos — Plano de implementação
TLDR: Torna síncrona a criação da
UserJourneyno signal de aceite de termos e remove a task Celerycreate_user_journey, que fica órfã.
Spec:
.project/docs/specs/20260826142256_journey_creation_race.mdBranch: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 = Trueemconfig/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 queJourneyService.create_foré chamado diretamente, sem passar por.delay()/task.- Nenhum outro chamador de
create_user_journeyexiste no código (confirmado via grep emapps/,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.pymockamapps.audios.signals.create_user_journey.delaye precisam ser reescritos para o novo comportamento síncrono; cinco testes emtests/audios/test_journey_task.pytestam 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"