diff --git a/sapl/integracao_hub/tests/test_poll.py b/sapl/integracao_hub/tests/test_poll.py index 30883ab1e..65ec83db8 100644 --- a/sapl/integracao_hub/tests/test_poll.py +++ b/sapl/integracao_hub/tests/test_poll.py @@ -8,7 +8,8 @@ from rest_framework.authtoken.models import Token from rest_framework.test import APIClient from sapl.base.models import Autor -from sapl.materia.models import Proposicao, Tramitacao +from sapl.materia.models import (Proposicao, StatusTramitacao, + Tramitacao) BASE = '/api/integracao/poll/' @@ -19,7 +20,9 @@ def cliente_hub(db): permissao = Permission.objects.get( content_type__app_label='integracao_hub', codename='pode_integrar') usuario.user_permissions.add(permissao) - token = Token.objects.create(user=usuario) + # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. + # Um create() aqui colide com a UNIQUE de authtoken_token. + token, _ = Token.objects.get_or_create(user=usuario) cliente = APIClient() cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) return cliente @@ -130,7 +133,9 @@ def test_desde_invalido_da_400(cliente_hub): @pytest.mark.django_db(transaction=False) def test_tramitacoes_por_cursor_de_id(cliente_hub): - tramitacao = baker.make(Tramitacao) + # status explicito: o envelope canonico exige situacao.id_origem + descricao, + # entao tramitacao sem status nao exercita o que importa. + tramitacao = baker.make(Tramitacao, status=baker.make(StatusTramitacao)) resposta = cliente_hub.get(BASE + 'tramitacoes/', {'id_gt': 0}) @@ -154,7 +159,9 @@ def test_limite_e_respeitado(cliente_hub): @pytest.mark.django_db(transaction=False) def test_poll_sem_permissao_da_403(db): usuario = baker.make('auth.User') - token = Token.objects.create(user=usuario) + # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. + # Um create() aqui colide com a UNIQUE de authtoken_token. + token, _ = Token.objects.get_or_create(user=usuario) cliente = APIClient() cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) diff --git a/sapl/integracao_hub/tests/test_recepcao.py b/sapl/integracao_hub/tests/test_recepcao.py index 17965500f..b869ea9a5 100644 --- a/sapl/integracao_hub/tests/test_recepcao.py +++ b/sapl/integracao_hub/tests/test_recepcao.py @@ -9,7 +9,10 @@ from rest_framework.test import APIClient from sapl.base.models import AppConfig, Autor from sapl.integracao_hub.models import EventoRecebido -from sapl.materia.models import Proposicao, TipoProposicao +from django.contrib.contenttypes.models import ContentType + +from sapl.materia.models import (MateriaLegislativa, Proposicao, + TipoProposicao) URL = '/api/integracao/proposicoes/' @@ -25,7 +28,9 @@ def cliente_hub(db): permissao = Permission.objects.get( content_type__app_label='integracao_hub', codename='pode_integrar') usuario.user_permissions.add(permissao) - token = Token.objects.create(user=usuario) + # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. + # Um create() aqui colide com a UNIQUE de authtoken_token. + token, _ = Token.objects.get_or_create(user=usuario) cliente = APIClient() cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) return cliente @@ -38,7 +43,11 @@ def autor(db): @pytest.fixture() def tipo(db): - return baker.make(TipoProposicao) + # content_type e NOT NULL e o baker nao preenche FK sozinho — no SAPL ele diz que + # conteudo a proposicao gera ao ser recebida (materia, no caso). + return baker.make( + TipoProposicao, + content_type=ContentType.objects.get_for_model(MateriaLegislativa)) def corpo(autor, tipo, **extras): @@ -152,7 +161,9 @@ def test_chave_invalida_da_422(cliente_hub, app_config, autor, tipo): @pytest.mark.django_db(transaction=False) def test_sem_permissao_da_403(db, app_config, autor, tipo): usuario = baker.make('auth.User') - token = Token.objects.create(user=usuario) + # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. + # Um create() aqui colide com a UNIQUE de authtoken_token. + token, _ = Token.objects.get_or_create(user=usuario) cliente = APIClient() cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) diff --git a/sapl/integracao_hub/tests/test_reconciliacao.py b/sapl/integracao_hub/tests/test_reconciliacao.py new file mode 100644 index 000000000..75b1ff84b --- /dev/null +++ b/sapl/integracao_hub/tests/test_reconciliacao.py @@ -0,0 +1,148 @@ +from datetime import timedelta + +import pytest +from django.contrib.auth.models import Permission +from django.utils import timezone +from model_bakery import baker +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from sapl.materia.models import Proposicao, Tramitacao + +INVENTARIO = '/api/integracao/reconciliacao/' +ENVIADAS = '/api/integracao/poll/proposicoes-enviadas/' + + +@pytest.fixture() +def cliente_hub(db): + usuario = baker.make('auth.User') + usuario.user_permissions.add( + Permission.objects.get( + content_type__app_label='integracao_hub', codename='pode_integrar')) + # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. + # Um create() aqui colide com a UNIQUE de authtoken_token. + token, _ = Token.objects.get_or_create(user=usuario) + cliente = APIClient() + cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) + return cliente + + +# ── Inventário: a lista de conferência da reconciliação ──────────────────────── + + +@pytest.mark.django_db(transaction=False) +def test_inventario_devolve_ids_acima_do_corte(cliente_hub): + antiga = baker.make(Proposicao, cancelado=False) + nova = baker.make(Proposicao, cancelado=False) + + resposta = cliente_hub.get(INVENTARIO, {'id_gt': antiga.pk}) + + assert resposta.status_code == 200 + assert nova.pk in resposta.data['proposicoes'] + assert antiga.pk not in resposta.data['proposicoes'] + + +@pytest.mark.django_db(transaction=False) +def test_inventario_ignora_cancelada(cliente_hub): + # Sem isso a reconciliação apontaria a cancelada como ausente para sempre — + # o poller nunca a traz, e o hub nunca a conheceria. + cancelada = baker.make(Proposicao, cancelado=True) + + resposta = cliente_hub.get(INVENTARIO, {'id_gt': 0}) + + assert cancelada.pk not in resposta.data['proposicoes'] + + +@pytest.mark.django_db(transaction=False) +def test_inventario_avisa_quando_a_faixa_encheu(cliente_hub): + baker.make(Proposicao, cancelado=False, _quantity=3) + + resposta = cliente_hub.get(INVENTARIO, {'id_gt': 0, 'limite': 2}) + + assert len(resposta.data['proposicoes']) == 2 + assert resposta.data['truncado'] is True # o hub sabe que falta pedir mais + + +@pytest.mark.django_db(transaction=False) +def test_inventario_cursores_independentes_para_tramitacao(cliente_hub): + # Proposição e tramitação são sequências separadas: um cursor só misturaria as faixas. + proposicao = baker.make(Proposicao, cancelado=False) + tramitacao = baker.make(Tramitacao) + + resposta = cliente_hub.get( + INVENTARIO, {'id_gt': proposicao.pk - 1, 'tramitacao_id_gt': tramitacao.pk}) + + assert proposicao.pk in resposta.data['proposicoes'] + assert tramitacao.pk not in resposta.data['tramitacoes'] + + +@pytest.mark.django_db(transaction=False) +def test_inventario_sem_permissao_da_403(db): + usuario = baker.make('auth.User') + # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. + # Um create() aqui colide com a UNIQUE de authtoken_token. + token, _ = Token.objects.get_or_create(user=usuario) + cliente = APIClient() + cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) + + assert cliente.get(INVENTARIO, {'id_gt': 0}).status_code == 403 + + +# ── Keyset: o empate de timestamp que travava a fonte para sempre ────────────── + + +@pytest.mark.django_db(transaction=False) +def test_paginacao_por_data_desempata_por_id_e_avanca(cliente_hub): + """O cenário que travava: uma página inteira com o MESMO timestamp. + + Com cursor só de data e `>=`, a segunda chamada devolvia exatamente a mesma + página — o cursor não avançava e nada depois daquele instante era lido, sem + erro nenhum. Com (data, id) a segunda página continua de onde a primeira parou. + """ + instante = timezone.now() - timedelta(hours=1) + tres = [baker.make(Proposicao, data_envio=instante, cancelado=False) + for _ in range(3)] + ids = sorted(p.pk for p in tres) + + primeira = cliente_hub.get( + ENVIADAS, {'desde': instante.isoformat(), 'id_gt': 0, 'limite': 2}) + assert [p['id'] for p in primeira.data['resultados']] == ids[:2] + + # O hub avança o cursor para (mesmo instante, último id lido). + segunda = cliente_hub.get( + ENVIADAS, {'desde': instante.isoformat(), 'id_gt': ids[1], 'limite': 2}) + + assert [p['id'] for p in segunda.data['resultados']] == [ids[2]] # progrediu + + +@pytest.mark.django_db(transaction=False) +def test_paginacao_por_data_nao_repete_o_ja_lido_no_mesmo_instante(cliente_hub): + instante = timezone.now() - timedelta(hours=2) + p1 = baker.make(Proposicao, data_envio=instante, cancelado=False) + + resposta = cliente_hub.get( + ENVIADAS, {'desde': instante.isoformat(), 'id_gt': p1.pk}) + + assert p1.pk not in [p['id'] for p in resposta.data['resultados']] + + +@pytest.mark.django_db(transaction=False) +def test_paginacao_por_data_traz_o_que_veio_depois_do_instante(cliente_hub): + instante = timezone.now() - timedelta(hours=3) + depois = baker.make( + Proposicao, data_envio=instante + timedelta(minutes=5), cancelado=False) + + resposta = cliente_hub.get( + ENVIADAS, {'desde': instante.isoformat(), 'id_gt': 999999}) + + # id_gt alto não pode esconder quem tem timestamp maior — o desempate só vale + # dentro do mesmo instante. + assert depois.pk in [p['id'] for p in resposta.data['resultados']] + + +@pytest.mark.django_db(transaction=False) +def test_paginacao_por_data_id_gt_invalido_da_400(cliente_hub): + resposta = cliente_hub.get( + ENVIADAS, {'desde': timezone.now().isoformat(), 'id_gt': 'abc'}) + + assert resposta.status_code == 400 diff --git a/sapl/integracao_hub/urls.py b/sapl/integracao_hub/urls.py index a0693b90b..a403cc14e 100644 --- a/sapl/integracao_hub/urls.py +++ b/sapl/integracao_hub/urls.py @@ -1,6 +1,7 @@ from django.conf.urls import url -from .views import (ProposicoesCadastradasPollView, +from .views import (InventarioView, + ProposicoesCadastradasPollView, ProposicoesDevolvidasPollView, ProposicoesEnviadasPollView, ProposicoesRecebidasPollView, @@ -31,4 +32,8 @@ urlpatterns = [ url(r'^api/integracao/poll/tramitacoes/$', TramitacoesPollView.as_view(), name='integracao_hub_poll_tramitacoes'), + + url(r'^api/integracao/reconciliacao/$', + InventarioView.as_view(), + name='integracao_hub_reconciliacao'), ] diff --git a/sapl/integracao_hub/views.py b/sapl/integracao_hub/views.py index 17e890965..0aed2df09 100644 --- a/sapl/integracao_hub/views.py +++ b/sapl/integracao_hub/views.py @@ -2,6 +2,7 @@ import logging import uuid from django.db import IntegrityError, transaction +from django.db.models import Q from django.utils import timezone from django.utils.dateparse import parse_datetime from rest_framework import status @@ -186,6 +187,15 @@ class ProposicoesCadastradasPollView(PollView): class PollPorDataView(PollView): + """Paginação por chave composta (data, id). + + Com o cursor só de data e `>=`, uma página inteira de registros com o MESMO + timestamp devolvia sempre o mesmo valor de cursor: ele não avançava e a fonte + relia a mesma página para sempre, sem erro e sem nunca progredir. Chave composta + elimina o empate como classe de problema — `id_gt` desempata dentro do mesmo + instante (refinamento da reconciliação §1.1). + """ + campo_cursor = None def get(self, request, *args, **kwargs): @@ -194,9 +204,17 @@ class PollPorDataView(PollView): return Response( {'detalhe': 'desde deve ser um datetime ISO-8601'}, status=status.HTTP_400_BAD_REQUEST) - filtro = {'%s__gte' % self.campo_cursor: desde, 'cancelado': False} + id_gt = self._id_gt(request) + if id_gt is None: + return Response({'detalhe': 'id_gt deve ser inteiro'}, + status=status.HTTP_400_BAD_REQUEST) + + # (campo > desde) OU (campo = desde E id > id_gt) — keyset, sem pular nem repetir. + depois_do_instante = Q(**{'%s__gt' % self.campo_cursor: desde}) + no_mesmo_instante = (Q(**{self.campo_cursor: desde}) & Q(id__gt=id_gt)) + itens = (Proposicao.objects - .filter(**filtro) + .filter(depois_do_instante | no_mesmo_instante, cancelado=False) .select_related('tipo', 'autor', 'content_type') .order_by(self.campo_cursor, 'id')[:self._limite(request)]) return self._resposta(itens, serializar_proposicao) @@ -227,3 +245,46 @@ class TramitacoesPollView(PollView): .select_related('status') .order_by('id')[:self._limite(request)]) return self._resposta(itens, serializar_tramitacao) + + +class InventarioView(PollView): + """Lista de conferência para a reconciliação do hub (refinamento §3). + + Devolve **ids**, não objetos: é para o hub comparar com o que ele conhece e + descobrir o que ficou de fora, não uma segunda via dos dados. O corte é por id + porque `Proposicao` não tem campo de criação — o que existe são as datas de + estado (envio, recebimento, devolução), e nenhuma delas serve para "quando + apareceu". Mesmo filtro dos polls (`cancelado=False`), senão a reconciliação + apontaria para sempre as canceladas como ausentes. + + O SAPL continua sem conhecer o canônico (spec §1, princípio 2): ele só diz o + que tem. + """ + + def get(self, request, *args, **kwargs): + id_gt = self._id_gt(request) + if id_gt is None: + return Response({'detalhe': 'id_gt deve ser inteiro'}, + status=status.HTTP_400_BAD_REQUEST) + try: + tramitacao_id_gt = int( + request.query_params.get('tramitacao_id_gt', id_gt)) + except ValueError: + return Response({'detalhe': 'tramitacao_id_gt deve ser inteiro'}, + status=status.HTTP_400_BAD_REQUEST) + + limite = self._limite(request) + proposicoes = list( + Proposicao.objects.filter(id__gt=id_gt, cancelado=False) + .order_by('id').values_list('id', flat=True)[:limite]) + tramitacoes = list( + Tramitacao.objects.filter(id__gt=tramitacao_id_gt) + .order_by('id').values_list('id', flat=True)[:limite]) + + return Response({ + 'proposicoes': proposicoes, + 'tramitacoes': tramitacoes, + # Diz se a pagina encheu: o hub sabe que precisa pedir a proxima faixa + # em vez de concluir que o resto simplesmente nao existe. + 'truncado': len(proposicoes) >= limite or len(tramitacoes) >= limite, + })