"""
El tablero de la cuenta (V0): la raíz.

Existe porque la raíz redirigía al listado de dominios, y un listado es un
inventario, no un estado: para saber si algo andaba mal había que entrar dominio
por dominio.

Lo que se fija acá es lo que hace que el tablero no mienta. Es la pantalla donde
las cifras se leen sueltas y grandes, sin la vista alrededor que las explica, así
que las dos reglas de siempre pesan más: ninguna cifra sin su denominador (R-A) y
«todavía no preguntamos» nunca contado como «no indexada» (R-B).
"""

from datetime import datetime, time, timedelta

import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils import timezone

from apps.core.dates import display_timezone, today
from apps.credentials.models import CredentialStatus
from apps.domains.models import AccessState
from apps.jobs.models import Batch, BatchKind, BatchOrigin, BatchState, QuotaBudget
from apps.onboarding import services as onboarding
from apps.sitemaps.models import CoverageState, Sitemap, SitemapSource, Url
from tests.factories import create_account, create_credential, create_domain

pytestmark = pytest.mark.django_db


@pytest.fixture
def account(client):
    account = create_account()
    create_credential(account, status=CredentialStatus.VERIFIED)
    # El recorrido se omite: sin esto la raíz lo ofrece y nunca llega al tablero,
    # que es exactamente lo que verifica `test_without_connecting_the_root_offers_the_tour`.
    onboarding.dismiss(account)
    client.force_login(account)
    return account


@pytest.fixture
def domain(account):
    return create_domain(account, access_state=AccessState.OPERATIONAL)


def dashboard(client, **params) -> dict:
    """
    Las props del tablero, que vive adentro de la herramienta y no en la raíz.

    La raíz pasó a ser la elección de herramienta cuando el producto dejó de
    tener una sola.
    """
    response = client.get('/search-console', HTTP_X_INERTIA='true', **params)
    assert response.status_code == 200
    return response.json()['props']


def create_urls(domain, quantity: int, state: str, *, checked: bool = True) -> None:
    sitemap, _ = Sitemap.objects.get_or_create(
        domain=domain,
        location=f'https://{domain.hostname}/sitemap.xml',
        defaults={'source': SitemapSource.DECLARED},
    )
    for index in range(quantity):
        Url.objects.create(
            domain=domain,
            sitemap=sitemap,
            loc=f'https://{domain.hostname}/{state.lower()}-{index}',
            in_sitemap=True,
            coverage_state=state,
            last_checked_at=timezone.now() if checked else None,
        )


class TestTheRoot:
    """
    La raíz elige herramienta. Dejó de ser el tablero cuando dejó de haber una
    sola herramienta.
    """

    def test_the_root_offers_the_tools_and_does_not_redirect(self, client, account):
        response = client.get('/', HTTP_X_INERTIA='true')

        assert response.status_code == 200
        assert response.json()['component'] == 'Tools'

    def test_every_tool_travels_with_the_name_of_its_route(self, client, account):
        """
        Lo que viaja es el **nombre** de la ruta y no su dirección: la arma
        `route()` en el cliente, como toda dirección del producto. Mandarla ya
        resuelta ataría esta tabla al prefijo de hoy.

        La comprobación es sobre **todas** las herramientas y no sobre una lista
        escrita a mano: cuántas hay lo decide `TOOLS`, y clavarlo acá haría que
        publicar la próxima rompiera un test que no habla de eso.
        """
        tools = client.get('/', HTTP_X_INERTIA='true').json()['props']['tools']

        assert tools, 'La raíz no ofreció ninguna herramienta.'
        for tool in tools:
            # Un nombre de ruta, no una dirección: si esto trajera «/seo», la
            # tabla quedaría atada al prefijo que la herramienta tiene hoy.
            assert '/' not in tool['route']
            # El nombre visible no viaja: la pantalla lo busca en el catálogo por
            # el código, igual que la frase que explica para qué sirve.
            assert 'name' not in tool

    def test_search_console_is_the_first_tool_offered(self, client, account):
        """
        Search Console va primera, y no es un detalle de orden.

        Es la que tiene el recorrido guiado y la conexión con Google, y SEO se
        apoya en esa misma credencial: ofrecer SEO antes mandaría a una
        herramienta que todavía no puede funcionar.
        """
        tools = client.get('/', HTTP_X_INERTIA='true').json()['props']['tools']

        assert tools[0]['code'] == 'search-console'
        assert tools[0]['route'] == 'search_console'

    def test_without_connecting_the_root_does_not_offer_the_tour(self, client):
        """
        La raíz no dispara el recorrido, y ése es el cambio.

        El recorrido conecta **una** herramienta. Con varias, la raíz no puede
        elegir cuál sin quitarle a la persona lo único que esta pantalla le
        deja decidir. Esa redirección se mudó adentro de la herramienta.
        """
        client.force_login(create_account(email='nueva@ejemplo.test'))

        response = client.get('/', HTTP_X_INERTIA='true')

        assert response.status_code == 200
        assert response.json()['component'] == 'Tools'

    def test_without_a_session_the_root_sends_to_the_login(self, client):
        assert client.get('/').headers['Location'].startswith('/login')


class TestTheToolHome:
    """La raíz de Search Console: el tablero, y la única redirección que queda."""

    def test_it_is_the_dashboard_and_not_a_redirect(self, client, account):
        """El caso del reporte: todo el tráfico terminaba en el listado de dominios."""
        response = client.get('/search-console', HTTP_X_INERTIA='true')

        assert response.status_code == 200
        assert response.json()['component'] == 'Dashboard'

    def test_without_connecting_it_offers_the_tour(self, client):
        """
        La única redirección que queda, y tiene motivo: sin cuenta conectada no
        hay cobertura, ni cupo, ni lotes que mirar.
        """
        client.force_login(create_account(email='nueva@ejemplo.test'))

        response = client.get('/search-console')

        assert response.status_code == 302
        assert response.headers['Location'] == '/search-console/onboarding'

    def test_without_a_session_it_sends_to_the_login(self, client):
        assert client.get('/search-console').headers['Location'].startswith('/login')


class TestWhatNeedsAction:
    def test_a_domain_awaiting_access_appears_with_its_reason(self, client, account):
        create_domain(account, hostname='espera.test', access_state=AccessState.AWAITING_ACCESS)

        attention = dashboard(client)['attention']

        assert [d['hostname'] for d in attention['domains']] == ['espera.test']
        assert attention['domains'][0]['access_state'] == AccessState.AWAITING_ACCESS

    def test_an_operational_domain_asks_for_nothing(self, client, domain):
        assert dashboard(client)['attention']['domains'] == []

    def test_the_halted_states_are_not_offered_as_pending(self, client, account):
        """
        Revocado y suspendido se deciden desde la administración. Ofrecerlos acá
        mandaría a resolver algo que desde la aplicación no se resuelve.
        """
        create_domain(account, hostname='revocado.test', access_state=AccessState.ACCESS_REVOKED)
        create_domain(account, hostname='suspendido.test', access_state=AccessState.SUSPENDED)

        assert dashboard(client)['attention']['domains'] == []

    def test_with_the_credential_broken_it_is_said_separately(self, client, account):
        """
        Va separado de los dominios porque es la causa de que ninguno se pueda
        comprobar. Listarlos uno por uno mandaría a pantallas donde el botón no
        va a funcionar (RT-18).
        """
        account.credentials.update(status=CredentialStatus.INVALID)

        assert dashboard(client)['attention']['credential'] is not None

    def test_with_the_credential_healthy_there_is_nothing_to_say(self, client, account):
        assert dashboard(client)['attention']['credential'] is None

    def test_a_partial_batch_is_not_something_to_resolve(self, client, domain):
        """
        Un lote cortado por cupo no falló: lo que quedó entra en el ciclo
        siguiente sin que nadie haga nada (R-C). Ponerlo entre los pendientes
        pediría una acción que no existe.
        """
        Batch.objects.create(
            domain=domain,
            kind=BatchKind.URL_INSPECTION,
            origin=BatchOrigin.SCHEDULED,
            state=BatchState.PARTIAL,
            total_items=2000,
            processed_items=412,
            finished_at=timezone.now(),
        )

        assert dashboard(client)['attention']['failed_batches'] == []

    def test_an_old_failed_batch_stops_asking_for_attention(self, client, domain):
        """
        Un fallo de hace tres semanas por algo ya resuelto no es trabajo
        pendiente: es historia. Mostrarlo entrena a ignorar el bloque entero.
        """
        Batch.objects.create(
            domain=domain,
            kind=BatchKind.URL_INSPECTION,
            origin=BatchOrigin.SCHEDULED,
            state=BatchState.FAILED,
            finished_at=timezone.now() - timezone.timedelta(days=21),
        )

        assert dashboard(client)['attention']['failed_batches'] == []


class TestTheHonestyLine:
    def test_the_unchecked_ones_are_counted_separately(self, client, domain):
        """
        `UNKNOWN` significa «todavía no le preguntamos a Google». Meterlo en el
        reparto lo convertiría en «no está indexada», que es una afirmación sobre
        Google que no podemos hacer (R-B).
        """
        create_urls(domain, 30, CoverageState.INDEXED)
        create_urls(domain, 70, CoverageState.UNKNOWN, checked=False)

        coverage = dashboard(client)['coverage']

        assert coverage['total'] == 100
        assert coverage['with_data'] == 30
        assert coverage['without_data'] == 70

    def test_the_denominator_adds_up_the_domains_of_the_account(self, client, account, domain):
        other = create_domain(account, hostname='otro.test', access_state=AccessState.OPERATIONAL)
        create_urls(domain, 10, CoverageState.INDEXED)
        create_urls(other, 5, CoverageState.CRAWLED_NOT_INDEXED)

        coverage = dashboard(client)['coverage']

        assert coverage['with_data'] == 15
        assert coverage['domains_with_urls'] == 2

    def test_domains_created_by_another_profile_are_included(self, client, domain):
        foreign = create_account(email='ajena@ejemplo.test')
        create_urls(create_domain(foreign, hostname='ajeno.test'), 500, CoverageState.INDEXED)
        create_urls(domain, 10, CoverageState.INDEXED)

        assert dashboard(client)['coverage']['with_data'] == 510

    def test_the_datum_travels_with_the_date_on_which_it_was_obtained(self, client, domain):
        """Sin la fecha, «30 con dato» podría ser de hoy o de hace tres meses (R-A)."""
        create_urls(domain, 3, CoverageState.INDEXED)

        assert dashboard(client)['coverage']['last_checked_at'] is not None

    def test_it_is_said_when_the_datum_is_frozen(self, client, account, domain):
        """
        Sus URLs siguen sumando al total con lo último que se supo, así que hay
        que poder decir qué parte de la cifra dejó de actualizarse (R-F).

        Lo mira sobre el sitio conectado y no sobre un segundo dominio: el
        tablero habla del sitio, así que un dominio suelto en «acceso perdido»
        no tiene por qué congelar una cifra que no alimenta.
        """
        domain.access_state = AccessState.ACCESS_LOST
        domain.save(update_fields=['access_state'])

        assert dashboard(client)['coverage']['frozen_domains'] == 1


class TestTheActivity:
    def test_the_series_does_not_skip_the_days_without_activity(self, client, domain):
        """
        Un gráfico que une el lunes con el jueves dibuja una pendiente donde hubo
        dos días parados.
        """
        activity = dashboard(client, QUERY_STRING='days=7')['activity']

        assert activity['days'] == 7
        assert len(activity['series']) == 7
        assert all(point['queries'] == 0 for point in activity['series'])

    def test_the_queries_come_from_the_quota_each_batch_spent(self, client, domain):
        Batch.objects.create(
            domain=domain,
            kind=BatchKind.URL_INSPECTION,
            origin=BatchOrigin.SCHEDULED,
            state=BatchState.COMPLETED,
            total_items=120,
            processed_items=120,
            quota_consumed=120,
            started_at=timezone.now(),
            finished_at=timezone.now(),
        )

        assert dashboard(client)['activity']['total_queries'] == 120

    def test_a_synchronization_does_not_count_as_a_query(self, client, domain):
        """
        Leer sitemaps es tráfico hacia el sitio del usuario. Lo que se le
        consulta a Google es cada URL inspeccionada, y mezclarlos haría subir la
        línea sin que se le hubiera preguntado nada a nadie.
        """
        Batch.objects.create(
            domain=domain,
            kind=BatchKind.SITEMAP_SYNC,
            origin=BatchOrigin.MANUAL,
            state=BatchState.COMPLETED,
            quota_consumed=7,
            started_at=timezone.now(),
            finished_at=timezone.now(),
        )

        assert dashboard(client)['activity']['total_queries'] == 0

    def test_the_first_day_of_the_series_is_counted_whole(self, client, domain):
        """
        El corte es el comienzo del día, no «hace tantos días a esta hora».

        Con la hora adentro, un lote de esta madrugada quedaba afuera de su
        propio día cuando la pantalla se abría de tarde: la primera barra salía
        más baja que las demás por construcción, y es justo el extremo desde el
        que se lee la tendencia.
        """
        zone = display_timezone()
        start_of_day = datetime.combine(
            (timezone.localtime(timezone=zone) - timedelta(days=6)).date(),
            time(0, 30),
            tzinfo=zone,
        )
        Batch.objects.create(
            domain=domain,
            kind=BatchKind.URL_INSPECTION,
            origin=BatchOrigin.SCHEDULED,
            state=BatchState.COMPLETED,
            total_items=90,
            processed_items=90,
            quota_consumed=90,
            started_at=start_of_day,
            finished_at=start_of_day + timedelta(minutes=10),
        )

        activity = dashboard(client, QUERY_STRING='days=7')['activity']

        assert activity['series'][0]['queries'] == 90
        assert activity['total_queries'] == 90

    def test_an_invalid_range_falls_back_to_the_usual_one(self, client, domain):
        """Una dirección mal escrita no es algo que la persona pueda corregir leyendo."""
        assert dashboard(client, QUERY_STRING='days=999')['activity']['days'] == 30
        assert dashboard(client, QUERY_STRING='days=hola')['activity']['days'] == 30


class TestTheSixAspects:
    """
    Lo que la grilla necesita para contestar sus seis preguntas.

    Cada aspecto dice una cifra en la tarjeta y abre su detalle: el detalle
    también sale de acá, porque son agregados de cuenta que en el producto sólo
    existen por dominio. Lo que se fija en esta clase es que ninguno de los dos
    invente nada, y sobre todo que abrirlos por dominio no cambie lo que la cifra
    afirma.
    """

    def test_the_sitemaps_are_counted_declared_and_discovered_alike(
        self, client, account, domain
    ):
        Sitemap.objects.create(
            domain=domain,
            location='https://ejemplo.test/sitemap.xml',
            source=SitemapSource.DECLARED,
            last_read_at=timezone.now(),
        )
        Sitemap.objects.create(
            domain=domain,
            location='https://ejemplo.test/sitemap-noticias.xml',
            source=SitemapSource.DISCOVERED,
        )

        sitemaps = dashboard(client)['sitemaps']

        assert sitemaps['total'] == 2
        assert sitemaps['domains_with_sitemaps'] == 1
        assert {row['hostname']: row['total'] for row in sitemaps['domains']} == {'ejemplo.test': 2}
        # La fecha sale del que sí se leyó: uno descubierto y sin leer todavía no
        # tiene ninguna, y dejar el aspecto sin fecha por eso escondería que la
        # cifra de arriba sí está fechada.
        assert sitemaps['last_read_at'] is not None

    def test_a_site_without_any_sitemap_still_travels(self, client, account, domain):
        """
        El cero es la respuesta que se buscaba.

        Una cuenta sin URLs descubiertas casi siempre es una cuenta a la que le
        falta registrar un sitemap. Esconder al sitio que no tiene ninguno
        dejaría al aspecto contestando de qué no hablar.
        """
        sitemaps = dashboard(client)['sitemaps']

        assert sitemaps['total'] == 0
        assert sitemaps['domains_with_sitemaps'] == 0
        assert {row['hostname']: row['total'] for row in sitemaps['domains']} == {'ejemplo.test': 0}

    def test_with_one_domain_the_button_can_point_at_it(self, client, domain):
        """Mandar al listado a elegir entre uno es un clic que no decide nada."""
        assert dashboard(client)['domains']['only_id'] == str(domain.id)

    def test_the_dashboard_speaks_of_the_connected_site_and_of_no_other(
        self, client, account, domain
    ):
        """
        Con dos dominios activos —una cuenta anterior al sitio único— manda el
        más antiguo y el otro no se cuenta en ninguna cifra.

        Ya no hay nada que adivinar: antes, con varios, el tablero se negaba a
        elegir uno porque equivocarse mandaba a la pantalla que no era. Ahora la
        interfaz trabaja contra uno solo, y lo que importa es que el tablero y
        las demás pantallas se puedan cuadrar entre sí: si acá dijera «2» y el
        menú llevara a uno, ninguna de las dos cifras se podría explicar.
        """
        create_domain(account, hostname='otro.test', access_state=AccessState.OPERATIONAL)

        domains = dashboard(client)['domains']

        assert domains['only_id'] == str(domain.id)
        assert domains['total'] == 1

    def test_the_state_breakdown_leaves_the_unqueried_ones_out(self, client, domain):
        """
        El reparto es «lo que Google contestó», y de las sin consultar no
        contestó nada. Meterlas adentro las convertiría en «no indexadas» (R-B).
        """
        create_urls(domain, 3, CoverageState.INDEXED)
        create_urls(domain, 2, CoverageState.CRAWLED_NOT_INDEXED)
        create_urls(domain, 7, CoverageState.UNKNOWN, checked=False)

        coverage = dashboard(client)['coverage']

        assert coverage['by_state'] == {
            CoverageState.INDEXED: 3,
            CoverageState.CRAWLED_NOT_INDEXED: 2,
        }
        assert coverage['without_data'] == 7

    def test_the_breakdown_says_which_urls_and_not_only_how_many(self, client, domain):
        """
        «3 descubiertas y sin rastrear» sin decir cuáles no se puede atender.

        Antes la única salida era ir a la tabla de cobertura y rearmar el filtro
        a mano. Acá la cifra es del presente —es el estado que la URL tiene
        hoy—, así que la lista coincide con el número de al lado.
        """
        create_urls(domain, 2, CoverageState.CRAWLED_NOT_INDEXED)

        coverage = dashboard(client, data={'panel': 'coverage'})['coverage']

        assert len(coverage['urls_by_state'][CoverageState.CRAWLED_NOT_INDEXED]) == 2

    def test_the_addresses_do_not_travel_while_the_panel_is_closed(self, client, domain):
        """
        El tablero es la pantalla más visitada del producto y este detalle sólo
        se ve dentro de un panel que hay que abrir. Mandarlo siempre es recorrer
        el inventario en cada carga para llenar una lista que casi nunca se
        despliega.
        """
        create_urls(domain, 2, CoverageState.CRAWLED_NOT_INDEXED)

        coverage = dashboard(client)['coverage']

        assert coverage['urls_by_state'] == {}
        # La cifra sí viaja siempre: es lo que la tarjeta dibuja sin abrir nada.
        assert coverage['by_state'][CoverageState.CRAWLED_NOT_INDEXED] == 2

    def test_the_indexed_ones_are_left_out_of_the_addresses(self, client, domain):
        """
        Son las que nadie despliega y las que más pesan: un sitio sano las tiene
        casi todas ahí. Lo que alguien mira son los estados minoritarios.
        """
        create_urls(domain, 5, CoverageState.INDEXED)
        create_urls(domain, 1, CoverageState.CRAWLED_NOT_INDEXED)

        coverage = dashboard(client, data={'panel': 'coverage'})['coverage']

        assert CoverageState.INDEXED not in coverage['urls_by_state']
        assert CoverageState.CRAWLED_NOT_INDEXED in coverage['urls_by_state']

    def test_the_coverage_carries_the_sites_own_date(self, client, account, domain):
        """
        La fecha viaja en la fila del sitio (R-A).

        Con uno solo coincide con la de la cuenta, y el reparto se sigue leyendo
        por sitio: es la forma que sobrevive al día que la interfaz vuelva a
        admitir varios, y la que evita que una fecha describa a algo que no
        consultó.

        El dominio suelto que quedó de una cuenta anterior **no aporta una
        fila**: el tablero habla del sitio conectado.
        """
        other = create_domain(account, hostname='otro.test', access_state=AccessState.OPERATIONAL)
        create_urls(domain, 4, CoverageState.INDEXED)
        create_urls(other, 6, CoverageState.UNKNOWN, checked=False)

        rows = {row['hostname']: row for row in dashboard(client)['coverage']['domains']}

        assert list(rows) == ['ejemplo.test']
        assert rows['ejemplo.test']['with_data'] == 4
        assert rows['ejemplo.test']['indexed'] == 4
        assert rows['ejemplo.test']['last_checked_at'] is not None

    def test_a_domain_without_urls_has_no_coverage_row(self, client, account, domain):
        """Su fila diría «0 de 0» y mandaría a una pantalla vacía. Lo que le falta es un sitemap."""
        create_domain(account, hostname='vacio.test', access_state=AccessState.OPERATIONAL)
        create_urls(domain, 2, CoverageState.INDEXED)

        hostnames = [row['hostname'] for row in dashboard(client)['coverage']['domains']]

        assert hostnames == ['ejemplo.test']

    def test_the_quota_travels_opened_by_property(self, client, account, domain):
        """
        El cupo es de la propiedad en Search Console, no un saldo de la cuenta:
        la suma sirve para la tarjeta, pero lo que dice qué se puede consultar
        hoy es la fila del sitio, con sus dos bolsillos.

        El cupo de otro dominio activo que quedó de antes **no se suma**. Sumarlo
        anunciaría un saldo que ninguna pantalla deja gastar: el trabajo se
        encola contra el sitio conectado y contra ninguno más.
        """
        other = create_domain(account, hostname='otro.test', access_state=AccessState.OPERATIONAL)
        QuotaBudget.objects.create(
            domain=domain,
            date=today(),
            limit_total=2000,
            manual_reserve=200,
            used_automatic=412,
            used_manual=8,
        )
        QuotaBudget.objects.create(domain=other, date=today(), limit_total=1000, manual_reserve=100)

        quota = dashboard(client)['quota']

        assert quota['limit_total'] == 2000
        assert quota['used'] == 420
        assert quota['manual_reserve'] == 200
        assert [row['hostname'] for row in quota['domains']] == ['ejemplo.test']
        assert quota['domains'][0]['used_automatic'] == 412
        assert quota['domains'][0]['used_manual'] == 8

    def test_without_a_budget_today_there_is_no_row_to_show(self, client, domain):
        """Un dominio sin fila todavía no gastó nada hoy: contar su límite inventaría un saldo."""
        quota = dashboard(client)['quota']

        assert quota['domains'] == []
        assert quota['limit_total'] == 0

    def test_todays_batches_are_counted(self, client, domain):
        Batch.objects.create(
            domain=domain,
            kind=BatchKind.URL_INSPECTION,
            origin=BatchOrigin.SCHEDULED,
            state=BatchState.COMPLETED,
            started_at=timezone.now(),
            finished_at=timezone.now(),
        )

        assert dashboard(client)['activity']['batches_today'] == 1

    def test_yesterdays_batches_are_not_todays(self, client, domain):
        """
        El corte es el comienzo del día en la zona de presentación, igual que la
        serie del gráfico. «5 lotes hoy» contando los de anteayer no contesta si
        la cuenta se movió hoy.
        """
        batch = Batch.objects.create(
            domain=domain,
            kind=BatchKind.URL_INSPECTION,
            origin=BatchOrigin.SCHEDULED,
            state=BatchState.COMPLETED,
            started_at=timezone.now(),
            finished_at=timezone.now(),
        )
        Batch.objects.filter(pk=batch.pk).update(created_at=timezone.now() - timedelta(days=1))

        assert dashboard(client)['activity']['batches_today'] == 0


class TestTheRest:
    def test_the_last_batches_are_from_the_whole_account(self, client, account, domain):
        other = create_domain(account, hostname='otro.test', access_state=AccessState.OPERATIONAL)
        for target in (domain, other):
            Batch.objects.create(
                domain=target,
                kind=BatchKind.URL_INSPECTION,
                origin=BatchOrigin.SCHEDULED,
                state=BatchState.COMPLETED,
                started_at=timezone.now(),
                finished_at=timezone.now(),
            )

        hostnames = {batch['hostname'] for batch in dashboard(client)['last_batches']}

        assert hostnames == {'ejemplo.test', 'otro.test'}

    def test_the_polling_looks_at_the_whole_account(self, client, domain):
        """
        El lote en curso puede no estar entre los cinco más recientes, y mirar
        sólo los que se muestran dejaría el tablero quieto (RT-13).
        """
        Batch.objects.create(
            domain=domain,
            kind=BatchKind.URL_INSPECTION,
            origin=BatchOrigin.MANUAL,
            state=BatchState.RUNNING,
            total_items=100,
            started_at=timezone.now(),
        )

        work = dashboard(client)['active_work']

        assert [item['state'] for item in work] == [BatchState.RUNNING]

    def test_the_work_says_what_it_is_and_since_when(self, client, domain):
        """
        «Trabajo en curso» a secas es una afirmación que no se puede contradecir:
        un lote encolado hace dos días que ya no avanza se lee igual que uno que
        arrancó recién. Con el tipo y la fecha, quedarse trabado se ve.
        """
        Batch.objects.create(
            domain=domain,
            kind=BatchKind.SITEMAP_SYNC,
            origin=BatchOrigin.SCHEDULED,
            state=BatchState.QUEUED,
        )

        [work] = dashboard(client)['active_work']

        assert work['kind'] == BatchKind.SITEMAP_SYNC
        # En cola no hay `started_at`, y cae a cuándo se encoló: es lo que hay
        # que mostrar mientras no haya arrancado.
        assert work['started_at'] is not None

    def test_without_batches_there_is_no_reason_to_keep_polling(self, client, domain):
        assert dashboard(client)['active_work'] == []

    def test_an_account_without_domains_says_so(self, client, account):
        data = dashboard(client)

        assert data['has_domains'] is False
        assert data['coverage']['total'] == 0

    def test_adding_domains_does_not_add_queries(self, client, account, domain):
        """
        El tablero agrega sobre todos los dominios de la cuenta, que es
        exactamente donde es fácil que se cuele una consulta por fila. Con seis
        dominios el conteo tiene que ser el mismo que con uno.
        """
        # Una llamada de precalentamiento antes de medir: la primera petición de
        # la sesión arrastra consultas propias —sesión, cuenta— que no dependen
        # de cuántos dominios hay y ensucian la comparación.
        dashboard(client)

        with CaptureQueriesContext(connection) as with_one:
            dashboard(client)

        for number in range(2, 7):
            create_domain(
                account, hostname=f'dominio{number}.test', access_state=AccessState.OPERATIONAL
            )
        with CaptureQueriesContext(connection) as with_six:
            dashboard(client)

        assert len(with_six) == len(with_one)
