Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
"""Agent distribution: releases + secure enrollment.
|
||||
|
||||
Covers central/app/agent_distribution.py, enrollment.py, release_storage.py,
|
||||
rate_limit.py, scripts/publish_agent_release.py and the new admin pages in
|
||||
web.py. Security tests mirror the mission's checklist: expired/revoked/
|
||||
second-use/tampered download links, expired/replayed/wrong-installation/
|
||||
already-enrolled enrollment attempts, path traversal, rate limiting, and
|
||||
that no secret ever reaches the audit log.
|
||||
"""
|
||||
import dataclasses
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config, enrollment, rate_limit, release_storage
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentEnrollment, AgentRelease, AuditEvent, Installation, Tenant
|
||||
from central.scripts.publish_agent_release import publish
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
VERSION = "1.2.3"
|
||||
|
||||
|
||||
def make_release_zip(version=VERSION) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr(f"GTIFiscalAgent-{version}/gti-agent.ps1", "# fake install script\n")
|
||||
z.writestr(f"GTIFiscalAgent-{version}/VERSION", version)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def write_temp_zip(data: bytes, version=VERSION) -> Path:
|
||||
path = Path(tempfile.gettempdir()) / f"GTIFiscalAgent-{version}-{id(data)}.zip"
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
rate_limit._attempts.clear()
|
||||
yield
|
||||
rate_limit._attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path, monkeypatch):
|
||||
replaced = dataclasses.replace(
|
||||
config.settings,
|
||||
agent_release_storage_path=str(tmp_path / "releases"),
|
||||
agent_download_link_ttl_seconds=1800,
|
||||
agent_download_max_uses_default=1,
|
||||
agent_enrollment_ttl_seconds=86400,
|
||||
base_url="https://gtifiscal.example.gti",
|
||||
web_cookie_secure=False,
|
||||
)
|
||||
monkeypatch.setattr(config, "settings", replaced)
|
||||
# publish_agent_release.py is written to also run standalone (`python
|
||||
# central/scripts/publish_agent_release.py`), so it imports via a
|
||||
# sys.path-inserted "app" package (same convention as register_agent.py)
|
||||
# rather than "central.app" — a second, independent copy of every module
|
||||
# it touches, including config. Patch that copy's settings too, or the
|
||||
# release it publishes lands outside this test's tmp_path.
|
||||
if "app.config" in sys.modules:
|
||||
monkeypatch.setattr(sys.modules["app.config"], "settings", replaced)
|
||||
factory = setup_db()
|
||||
seed(factory) # tenant "Acme" + Installation "srv-01" + admin "operator"/"a-secure-password"
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
return client, factory
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def csrf(client):
|
||||
return client.cookies.get("gti_csrf", "")
|
||||
|
||||
|
||||
def publish_release(factory, version=VERSION):
|
||||
with factory() as session:
|
||||
release, _created = publish(session, write_temp_zip(make_release_zip(version), version), version=version)
|
||||
return release.id
|
||||
|
||||
|
||||
def tenant_id(factory):
|
||||
with factory() as session:
|
||||
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
# /agents/new only offers active GTI_GERENCIADOR-sourced tenants
|
||||
# (central/app/client_sync.py) — flip the shared fixture tenant so
|
||||
# existing form-based tests keep working under that rule.
|
||||
if tenant.source != "GTI_GERENCIADOR" or not tenant.active:
|
||||
tenant.source, tenant.external_client_id, tenant.active = "GTI_GERENCIADOR", tenant.id, True
|
||||
session.commit()
|
||||
return tenant.id
|
||||
|
||||
|
||||
def audits(factory):
|
||||
with factory() as session:
|
||||
return session.scalars(select(AuditEvent)).all()
|
||||
|
||||
|
||||
def bundle_enrollment_fields(content: bytes) -> dict:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as z:
|
||||
[entry] = [n for n in z.namelist() if n.endswith("enrollment.json")]
|
||||
return json.loads(z.read(entry))
|
||||
|
||||
|
||||
# ---------- publish_agent_release.py ----------
|
||||
|
||||
def test_publish_is_idempotent_and_rejects_hash_conflict(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
with factory() as session:
|
||||
zip_bytes = make_release_zip("9.9.9")
|
||||
release, created = publish(session, write_temp_zip(zip_bytes, "9.9.9"), version="9.9.9")
|
||||
assert created and release.status == "PUBLISHED" and release.version == "9.9.9"
|
||||
release2, created2 = publish(session, write_temp_zip(zip_bytes, "9.9.9"), version="9.9.9")
|
||||
assert not created2 and release2.id == release.id # identical bytes: no-op
|
||||
with factory() as session:
|
||||
with pytest.raises(SystemExit):
|
||||
publish(session, write_temp_zip(make_release_zip("9.9.9") + b"\x00", "9.9.9"), version="9.9.9")
|
||||
|
||||
|
||||
def test_publish_records_published_at(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
with factory() as session:
|
||||
release, created = publish(session, write_temp_zip(make_release_zip("9.9.9"), "9.9.9"), version="9.9.9")
|
||||
assert created and release.status == "PUBLISHED"
|
||||
assert release.published_at is not None # publishing stamps the moment, not left NULL
|
||||
|
||||
|
||||
def test_published_release_binary_never_stored_in_postgres(hub):
|
||||
_client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
with factory() as session:
|
||||
release = session.get(AgentRelease, release_id)
|
||||
assert release.storage_path == Path(release.storage_path).name # a bare filename, never a path with directories
|
||||
path = release_storage.safe_path_for(release.storage_path)
|
||||
assert path.exists() and path.read_bytes() # binary lives on disk, outside the DB
|
||||
|
||||
|
||||
# ---------- path traversal ----------
|
||||
|
||||
def test_release_storage_path_traversal_impossible(hub):
|
||||
for hostile in ("../../etc/passwd", "sub/dir.zip", "/etc/passwd", "..\\..\\windows\\system32", ""):
|
||||
with pytest.raises(ValueError):
|
||||
release_storage.safe_path_for(hostile)
|
||||
|
||||
|
||||
# ---------- full happy path: create -> link -> download -> enroll ----------
|
||||
|
||||
def test_full_installation_flow_creates_a_working_agent(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
|
||||
"display_name": "Servidor Principal", "expected_hostname": "SRV-UNIAO",
|
||||
"release_id": release_id}, follow_redirects=False)
|
||||
assert created.status_code == 303
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
|
||||
linked = client.post(f"/agents/pending/{installation_uuid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert linked.status_code == 303
|
||||
download_url = linked.headers["location"].split("link=", 1)[1]
|
||||
download_token = download_url.rsplit("/", 1)[-1]
|
||||
|
||||
# A technician on the client machine never has an admin session.
|
||||
anon = TestClient(client.app)
|
||||
downloaded = anon.get(f"/dl/agent/{download_token}")
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.headers["content-type"] == "application/octet-stream"
|
||||
assert "attachment" in downloaded.headers["content-disposition"]
|
||||
|
||||
payload = bundle_enrollment_fields(downloaded.content)
|
||||
assert payload["installation_id"] == installation_uuid
|
||||
|
||||
enrolled = anon.post("/v1/agents/enroll", json={
|
||||
"installation_id": payload["installation_id"], "enrollment_token": payload["enrollment_token"],
|
||||
"agent_version": "1.2.3", "hostname": "SRV-UNIAO", "platform": "windows", "machine_fingerprint": "abc123",
|
||||
})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
|
||||
|
||||
with factory() as session:
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.2.3"
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
assert installation.installation_id == installation_uuid and installation.hostname == "SRV-UNIAO"
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "ENROLLED" and enr.enrollment_token_hash is None and enr.agent_id == agent.id
|
||||
|
||||
# Replay: the very same enrollment token can never enroll a second Agent.
|
||||
replay = anon.post("/v1/agents/enroll", json={"installation_id": payload["installation_id"], "enrollment_token": payload["enrollment_token"]})
|
||||
assert replay.status_code == 401 and replay.json()["detail"] == "ALREADY_ENROLLED"
|
||||
|
||||
# Second use of the (single-use) download link is also rejected.
|
||||
redownload = anon.get(f"/dl/agent/{download_token}")
|
||||
assert redownload.status_code == 404
|
||||
|
||||
events = {event.action for event in audits(factory)}
|
||||
assert {"INSTALLATION_CREATED", "DOWNLOAD_LINK_CREATED", "PACKAGE_DOWNLOADED", "ENROLLMENT_SUCCEEDED", "AGENT_CREDENTIAL_ISSUED", "ENROLLMENT_REJECTED"} <= events
|
||||
|
||||
secrets_leaked = [download_token, payload["enrollment_token"], body["agent_token"]]
|
||||
for event in audits(factory):
|
||||
haystack = json.dumps({"action": event.action, "target": event.target_id, "context": event.context})
|
||||
for secret in secrets_leaked:
|
||||
assert secret not in haystack
|
||||
|
||||
|
||||
def test_direct_authenticated_download_does_not_need_a_link(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
|
||||
"display_name": "Loja 2", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
|
||||
assert downloaded.status_code == 200
|
||||
payload = bundle_enrollment_fields(downloaded.content)
|
||||
assert payload["installation_id"] == installation_uuid
|
||||
|
||||
|
||||
# ---------- route precedence: /agents/new vs /agents/{agent_id} ----------
|
||||
|
||||
def test_get_agents_new_renders_form_not_captured_by_dynamic_route(hub):
|
||||
client, factory = hub
|
||||
publish_release(factory)
|
||||
tenant_id(factory) # at least one active GTI_GERENCIADOR tenant, or the form shows the empty state instead
|
||||
login(client)
|
||||
response = client.get("/agents/new")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Nova instalação" in response.text
|
||||
assert "Criar instalação" in response.text
|
||||
|
||||
|
||||
def test_get_agents_releases_page_not_captured_by_dynamic_route(hub):
|
||||
client, _factory = hub
|
||||
login(client)
|
||||
response = client.get("/agents/releases")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Versões do Agent" in response.text
|
||||
|
||||
|
||||
def test_get_agent_detail_still_renders_for_valid_numeric_id(hub):
|
||||
client, factory = hub
|
||||
login(client)
|
||||
with factory() as session:
|
||||
agent_id = session.scalar(select(Agent.id))
|
||||
response = client.get(f"/agents/{agent_id}")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
def test_get_agents_non_numeric_slug_is_controlled_404_and_never_captures_statics(hub):
|
||||
client, _factory = hub
|
||||
login(client)
|
||||
response = client.get("/agents/abc")
|
||||
assert response.status_code == 404
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
# the legitimate static routes keep working regardless of the slug above
|
||||
assert client.get("/agents/new").status_code == 200
|
||||
assert client.get("/agents/releases").status_code == 200
|
||||
|
||||
|
||||
# ---------- download-link security ----------
|
||||
|
||||
def _pending(factory, release_id, **overrides):
|
||||
import uuid as uuid_mod
|
||||
with factory() as session:
|
||||
enr = AgentEnrollment(enrollment_uuid=str(uuid_mod.uuid4()), tenant_id=tenant_id(factory),
|
||||
display_name="Teste", release_id=release_id, status="PENDING", **overrides)
|
||||
session.add(enr); session.commit()
|
||||
return enr.enrollment_uuid
|
||||
|
||||
|
||||
def test_expired_download_link_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid = _pending(factory, release_id)
|
||||
token = enrollment.new_token()
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
enr.download_token_hash = enrollment.token_digest(token)
|
||||
enr.download_token_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
enr.download_max_uses = 1
|
||||
session.commit()
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
def test_revoked_installation_link_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
|
||||
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
|
||||
uid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
token = linked.headers["location"].split("link=", 1)[1].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/pending/{uid}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
def test_wrong_or_random_download_token_rejected(hub):
|
||||
client, factory = hub
|
||||
publish_release(factory)
|
||||
assert client.get("/dl/agent/definitely-not-a-real-token").status_code == 404
|
||||
|
||||
|
||||
def test_release_revoked_blocks_download(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
|
||||
"display_name": "Teste", "release_id": release_id}, follow_redirects=False)
|
||||
uid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
linked = client.post(f"/agents/pending/{uid}/link", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
token = linked.headers["location"].split("link=", 1)[1].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/releases/{release_id}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert client.get(f"/dl/agent/{token}").status_code == 404
|
||||
|
||||
|
||||
# ---------- enrollment security ----------
|
||||
|
||||
def _enrollable(factory, release_id, **overrides):
|
||||
token = enrollment.new_token()
|
||||
uid = _pending(factory, release_id)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = overrides.pop("expires_at", datetime.now(timezone.utc) + timedelta(hours=1))
|
||||
for key, value in overrides.items():
|
||||
setattr(enr, key, value)
|
||||
session.commit()
|
||||
return uid, token
|
||||
|
||||
|
||||
def test_expired_enrollment_token_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, expires_at=datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "EXPIRED"
|
||||
|
||||
|
||||
def test_wrong_installation_uuid_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
_uid, token = _enrollable(factory, release_id)
|
||||
import uuid as uuid_mod
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": str(uuid_mod.uuid4()), "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_INSTALLATION"
|
||||
|
||||
|
||||
def test_malformed_installation_id_rejected(hub):
|
||||
client, _factory = hub
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_INSTALLATION"
|
||||
|
||||
|
||||
def test_random_enrollment_token_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, _token = _enrollable(factory, release_id)
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": enrollment.new_token()})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "INVALID_TOKEN"
|
||||
|
||||
|
||||
def test_revoked_installation_enroll_rejected(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, status="REVOKED")
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token})
|
||||
assert response.status_code == 401 and response.json()["detail"] == "REVOKED"
|
||||
|
||||
|
||||
def test_hostname_mismatch_is_flagged_but_not_blocked(hub):
|
||||
client, factory = hub
|
||||
release_id = publish_release(factory)
|
||||
uid, token = _enrollable(factory, release_id, expected_hostname="SRV-EXPECTED")
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": uid, "enrollment_token": token, "hostname": "SRV-OUTRO"})
|
||||
assert response.status_code == 200 and response.json()["hostname_mismatch"] is True
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uid))
|
||||
assert enr.hostname_mismatch is True and enr.status == "ENROLLED"
|
||||
|
||||
|
||||
# ---------- rate limiting ----------
|
||||
|
||||
def test_enroll_rate_limited_after_repeated_invalid_attempts(hub):
|
||||
client, _factory = hub
|
||||
for _ in range(rate_limit.MAX_ATTEMPTS):
|
||||
response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert response.status_code == 401
|
||||
blocked_response = client.post("/v1/agents/enroll", json={"installation_id": "not-a-uuid", "enrollment_token": "x"})
|
||||
assert blocked_response.status_code == 429
|
||||
|
||||
|
||||
def test_rate_limit_buckets_are_independent_per_ip(hub):
|
||||
# Unit-level proof that the limiter itself keys on (bucket, ip) rather than a
|
||||
# shared global counter — the end-to-end TestClient can't vary the caller's
|
||||
# apparent IP (that requires the real ProxyHeadersMiddleware, wired up only
|
||||
# under uvicorn --proxy-headers in production, per FORWARDED_ALLOW_IPS).
|
||||
for _ in range(rate_limit.MAX_ATTEMPTS):
|
||||
rate_limit.record_attempt("enroll", "198.51.100.1")
|
||||
assert rate_limit.blocked("enroll", "198.51.100.1")
|
||||
assert not rate_limit.blocked("enroll", "198.51.100.2")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Static checks on deploy/agent-windows/gti-agent.ps1's `install-auto` command
|
||||
(the zero-touch path driven by the enrollment bundle from the panel). No
|
||||
Windows host is available in CI, so these are text-level guarantees — the
|
||||
same properties are exercised for real in the manual Windows smoke test
|
||||
described in docs/PILOT_DEPLOYMENT.md.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = (Path(__file__).parents[2] / "deploy" / "agent-windows" / "gti-agent.ps1").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def install_auto_block() -> str:
|
||||
start = SCRIPT.index("'install-auto' {")
|
||||
end = SCRIPT.index("'configure' {")
|
||||
return SCRIPT[start:end]
|
||||
|
||||
|
||||
def test_install_auto_is_a_registered_command():
|
||||
validate_set = re.search(r"ValidateSet\(([^)]*)\)", SCRIPT).group(1)
|
||||
assert "'install-auto'" in validate_set
|
||||
|
||||
|
||||
def test_install_auto_never_enables_firebird_writes():
|
||||
block = install_auto_block()
|
||||
assert "FIREBIRD_WRITES=0" in block
|
||||
assert "FIREBIRD_WRITES=1" not in SCRIPT # nowhere in the whole script, not just this block
|
||||
|
||||
|
||||
def test_install_auto_refuses_a_non_https_hub():
|
||||
assert "hub_url -notmatch '^https://'" in install_auto_block()
|
||||
|
||||
|
||||
def test_install_auto_verifies_package_sha256_before_installing():
|
||||
block = install_auto_block()
|
||||
assert "SHA256_VALIDATION" in block and "Get-FileHash" in block
|
||||
assert block.index("SHA256_VALIDATION") < block.index("Invoke-RestMethod") # integrity checked before any network call
|
||||
|
||||
|
||||
def test_install_auto_deletes_the_enrollment_bundle_after_use():
|
||||
block = install_auto_block()
|
||||
assert "Remove-Item $EnrollmentFile -Force" in block
|
||||
assert block.index("Remove-Item $EnrollmentFile") > block.index("Invoke-RestMethod") # only after a successful enroll
|
||||
|
||||
|
||||
def test_install_auto_registers_but_never_starts_the_service():
|
||||
block = install_auto_block()
|
||||
assert "install" in block and "Start-Service" not in block # Firebird config is still missing; starting it is a separate, explicit step
|
||||
|
||||
|
||||
def test_install_auto_never_hardcodes_a_client_firebird_path():
|
||||
block = install_auto_block()
|
||||
assert "'FIREBIRD_DATABASE_PATH='" in block # written, but as an empty placeholder (no real path baked in)
|
||||
|
||||
|
||||
def test_install_auto_restricts_the_env_file_acl():
|
||||
block = install_auto_block()
|
||||
assert "icacls $EnvFile /inheritance:r /grant:r 'Administrators:(F)' 'SYSTEM:(F)'" in block
|
||||
|
||||
|
||||
def test_install_auto_is_idempotent_when_already_enrolled():
|
||||
block = install_auto_block()
|
||||
assert "Already enrolled and installed" in block and "exit 0" in block
|
||||
@@ -0,0 +1,16 @@
|
||||
from central.app.ai.budget_ledger import BudgetLedger, BudgetLimits
|
||||
|
||||
|
||||
def test_budget_rejects_before_provider_when_daily_limit_is_exceeded():
|
||||
ledger = BudgetLedger(BudgetLimits(max_run_cost_usd=2.0, max_daily_cost_usd=1.0))
|
||||
assert ledger.check_and_reserve("r1", "t1", 0.75, 1, 0).allowed is True
|
||||
decision = ledger.check_and_reserve("r2", "t1", 0.30, 1, 0)
|
||||
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "AI_BUDGET_EXHAUSTED"
|
||||
|
||||
|
||||
def test_budget_allows_exact_limit_and_counts_new_products():
|
||||
ledger = BudgetLedger(BudgetLimits(max_run_cost_usd=1.0, max_new_products_per_run=2))
|
||||
assert ledger.check_and_reserve("r1", "t1", 1.0, 1, 2).allowed is True
|
||||
assert ledger.check_and_reserve("r1", "t1", 0.0, 0, 1).allowed is False
|
||||
@@ -0,0 +1,33 @@
|
||||
from central.app.ai.cache_policy import CONFLICT, NEEDS_REVALIDATION, VALID, cache_key, classify_knowledge_freshness
|
||||
from central.app.ai.provider import BudgetGuard
|
||||
|
||||
|
||||
def test_cache_key_changes_for_each_input_dimension():
|
||||
base = ("input", "candidates", "model", "prompt", {"ncm": "n1", "tipi": "t1"})
|
||||
key = cache_key(*base)
|
||||
assert key != cache_key("changed", *base[1:])
|
||||
assert key != cache_key(base[0], "changed", *base[2:])
|
||||
assert key != cache_key(base[0], base[1], "changed", *base[3:])
|
||||
assert key != cache_key(base[0], base[1], base[2], "changed", base[4])
|
||||
assert key != cache_key(*base[:4], {"ncm": "n2", "tipi": "t1"})
|
||||
assert key == cache_key(*base)
|
||||
|
||||
|
||||
def test_dataset_freshness_is_fail_closed():
|
||||
assert classify_knowledge_freshness({"ncm": "v1"}, {"ncm": "v1"}) == VALID
|
||||
assert classify_knowledge_freshness({"ncm": "v1"}, {"ncm": "v2"}) == CONFLICT
|
||||
assert classify_knowledge_freshness({"ncm": "v1"}, {"ncm": "v1", "tipi": "v2"}) == NEEDS_REVALIDATION
|
||||
assert classify_knowledge_freshness({}, {"ncm": "v1"}) == NEEDS_REVALIDATION
|
||||
|
||||
|
||||
def test_budget_blocks_before_call_and_actual_usage_cannot_exceed_limit():
|
||||
guard = BudgetGuard(1.0, 1.0, 2.0)
|
||||
assert guard.authorize(guard.estimate(600_000, 300_000)) is False
|
||||
assert guard.authorize(guard.estimate(100_000, 50_000)) is True
|
||||
assert guard.authorize_usage(100_000, 50_000, prior_cost_usd=0.9) is False
|
||||
|
||||
|
||||
def test_unknown_cost_requires_explicit_limited_run():
|
||||
guard = BudgetGuard(5.0, None, None)
|
||||
assert guard.authorize_usage(1000, 100) is False
|
||||
assert guard.authorize_usage(1000, 100, allow_unknown_cost=True) is True
|
||||
@@ -0,0 +1,15 @@
|
||||
from central.app.product_data_provider import result_cache_key
|
||||
|
||||
|
||||
def test_same_classification_inputs_have_same_cache_key():
|
||||
first = result_cache_key({"gtin": "789", "description": "X"}, ["12345678"], {"ncm": "v1"}, "prompt-v1", "model-v1")
|
||||
second = result_cache_key({"description": "X", "gtin": "789"}, ["12345678"], {"ncm": "v1"}, "prompt-v1", "model-v1")
|
||||
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_changed_dataset_changes_cache_key():
|
||||
first = result_cache_key({"gtin": "789"}, ["12345678"], {"ncm": "v1"}, "prompt-v1", "model-v1")
|
||||
second = result_cache_key({"gtin": "789"}, ["12345678"], {"ncm": "v2"}, "prompt-v1", "model-v1")
|
||||
|
||||
assert first != second
|
||||
@@ -0,0 +1,181 @@
|
||||
"""AI semantic layer V1: fingerprint, KB, dedup, provider contract, budget guard, candidates."""
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import Base, Dataset, NcmEntry, CestEntry, CestNcmEntry, TipiEntry
|
||||
|
||||
|
||||
def _engine():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def _ncm_dataset(session, entries):
|
||||
dataset = Dataset(kind="NCM", provider="Siscomex", sha256="a" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=len(entries))
|
||||
session.add(dataset); session.flush()
|
||||
for code, description in entries:
|
||||
session.add(NcmEntry(dataset_id=dataset.id, code=code, description=description, valid_from="", valid_to=""))
|
||||
session.flush()
|
||||
return dataset
|
||||
|
||||
|
||||
# ---------------- fingerprint / identity ----------------
|
||||
|
||||
def test_fingerprint_is_stable_and_ignores_unstable_context():
|
||||
from central.app.ai.fingerprint import normalize_description, product_fingerprint, validate_gtin
|
||||
a = product_fingerprint("Coca-Cola 2LT", "COCA-COLA", "UN", "2")
|
||||
b = product_fingerprint("coca cola 2lt ", "coca-cola", "un", "2.0")
|
||||
assert a == b
|
||||
# fiscal/company context never participates in global identity
|
||||
c = product_fingerprint("Coca-Cola 2LT", "COCA-COLA", "UN", "2")
|
||||
assert a == c
|
||||
assert normalize_description("Água Mineral 500ml") == "AGUA MINERAL 500ML"
|
||||
assert validate_gtin("7891234567895") == "7891234567895" # valid GTIN-13
|
||||
assert validate_gtin("7891234567890") is None # bad checksum
|
||||
assert validate_gtin("123") is None
|
||||
|
||||
|
||||
# ---------------- knowledge base ----------------
|
||||
|
||||
def test_kb_exact_fingerprint_hit_and_conflict_without_majority():
|
||||
from central.app.ai.kb import KnowledgeBase, EXACT, FINGERPRINT
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
kb = KnowledgeBase(session)
|
||||
p1 = kb.upsert_product(fingerprint="f1", gtin="7891234567895", normalized_description="AGUA MINERAL 500ML")
|
||||
# same GTIN arrives with a different fingerprint -> same product, exact hit
|
||||
p2, hit = kb.resolve(gtin="7891234567895", fingerprint="other")
|
||||
assert p2.id == p1.id and hit == EXACT
|
||||
# same fingerprint, no GTIN -> fingerprint hit
|
||||
p3, hit = kb.resolve(gtin=None, fingerprint="f2")
|
||||
assert p3 is None and hit is None
|
||||
p4 = kb.upsert_product(fingerprint="f2", normalized_description="OUTRO")
|
||||
_, hit = kb.resolve(gtin=None, fingerprint="f2")
|
||||
assert hit == FINGERPRINT
|
||||
kb.add_knowledge(p1.id, "candidate_ncm", "22011000", source="OFFICIAL_DATASET", confidence=1.0)
|
||||
kb.add_knowledge(p1.id, "candidate_ncm", "22019000", source="OFFICIAL_DATASET", confidence=1.0)
|
||||
current = kb.current_knowledge(p1.id)
|
||||
assert current["candidate_ncm"]["conflict"] is True # no majority auto-pick
|
||||
assert current["candidate_ncm"]["values"] == {"22011000", "22019000"}
|
||||
stats = kb.stats()
|
||||
assert stats["products"] == 2 and stats["gtin_products"] == 1 and stats["conflicts"] == 1
|
||||
|
||||
|
||||
def test_kb_aggregates_observations_across_tenants():
|
||||
from central.app.ai.kb import KnowledgeBase
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
kb = KnowledgeBase(session)
|
||||
p = kb.upsert_product(fingerprint="f1", normalized_description="REFRIGERANTE 2L")
|
||||
kb.record_observation(p.id, tenant_id=1, company_key="MMMODAS", erp_code="000001", database_label="MMMODAS", observed_fields={"ncm": "22021000"})
|
||||
kb.record_observation(p.id, tenant_id=2, company_key="UNIAO", erp_code="000009", database_label="UNIAO", observed_fields={"ncm": "22021000"})
|
||||
agg = kb.observation_summary(p.id)
|
||||
assert agg["observation_count"] == 2 and agg["tenant_count"] == 2
|
||||
assert agg["agreement"]["ncm"] == {"22021000": 2} and agg["conflicts"] == {}
|
||||
|
||||
|
||||
# ---------------- work item dedup ----------------
|
||||
|
||||
def test_work_items_dedup_by_knowledge_needed():
|
||||
from central.app.ai.workitems import build_work_items
|
||||
questions = [
|
||||
{"QUESTION_ID": "Q1", "DATABASE": "MMMODAS", "COMPANY": "MMMODAS", "SCOPE": "NCM", "GROUP": "", "NCM": "33059000", "KEY": "ex_tipi", "CELLS": 10},
|
||||
{"QUESTION_ID": "Q2", "DATABASE": "UNIAO", "COMPANY": "UNIAO", "SCOPE": "NCM", "GROUP": "", "NCM": "33059000", "KEY": "ex_tipi", "CELLS": 5},
|
||||
{"QUESTION_ID": "Q3", "DATABASE": "RINCAO", "COMPANY": "RINCAO", "SCOPE": "GROUP", "GROUP": "000015", "KEY": "product_role", "CELLS": 40},
|
||||
{"QUESTION_ID": "Q4", "DATABASE": "CONSTRUCAO", "COMPANY": "CONSTRUCAO", "SCOPE": "PRODUCT", "GROUP": "", "NCM": "", "KEY": "ncm", "CELLS": 8, "QUESTION": "produto PRODUCT 000054", "DATABASE_CODE": "000054"},
|
||||
]
|
||||
items = build_work_items(questions, product_fingerprints={"CONSTRUCAO:000054": "fp-a"})
|
||||
# 4 raw questions -> 3 deduped work items (two ex_tipi questions for NCM 33059000 collapse)
|
||||
by_key = {i.dedup_key: i for i in items}
|
||||
assert len(items) == 3
|
||||
assert by_key["ex_tipi:33059000"].cells_covered == 15
|
||||
assert by_key["ex_tipi:33059000"].question_refs == ["Q1", "Q2"]
|
||||
assert by_key["product_role:RINCAO:000015"].cells_covered == 40
|
||||
assert by_key["invalid_ncm:fp-a"].cells_covered == 8
|
||||
# AI-ineligible keys never become work items
|
||||
assert all(i.task_type in ("INVALID_NCM", "CEST", "EX_TIPI", "PRODUCT_ROLE") for i in items)
|
||||
|
||||
|
||||
def test_work_items_accept_canonical_identity_map():
|
||||
from central.app.ai.workitems import build_work_items
|
||||
questions = [
|
||||
{"QUESTION_ID": "Q1", "DATABASE": "A", "SCOPE": "PRODUCT", "KEY": "ncm", "CELLS": 1, "DATABASE_CODE": "1"},
|
||||
{"QUESTION_ID": "Q2", "DATABASE": "B", "SCOPE": "PRODUCT", "KEY": "ncm", "CELLS": 1, "DATABASE_CODE": "2"},
|
||||
]
|
||||
items = build_work_items(questions, canonical_products={"A:1": "GTIN:7891234567895", "B:2": "GTIN:7891234567895"})
|
||||
assert len(items) == 1 and items[0].cells_covered == 2
|
||||
|
||||
|
||||
# ---------------- provider contract / structured output / budget ----------------
|
||||
|
||||
def test_structured_output_schema_and_baseline_provider():
|
||||
from central.app.ai.provider import BaselineProvider, result_from_dict, SemanticOutputError
|
||||
provider = BaselineProvider()
|
||||
item = type("W", (), {"task_type": "INVALID_NCM", "dedup_key": "invalid_ncm:fp-a", "payload": {"description": "AGUA MINERAL NATURAL 500ML"}})()
|
||||
candidates = [("22011000", "AGUAS MINERAIS NATURAIS", 0.75), ("22019000", "AGUAS ... OUTRAS", 0.25)]
|
||||
result = provider.select(item, candidates, dataset_versions={"ncm": "sha-ncm"})
|
||||
assert result.task_type == "INVALID_NCM" and result.selected_candidate == "22011000"
|
||||
assert result.candidate_ids_seen == ["22011000", "22019000"]
|
||||
assert result.model == "baseline-deterministic-v1" and result.prompt_version == "ai-semantic-v1"
|
||||
assert result.dataset_versions and result.engine_version
|
||||
as_dict = result.to_dict()
|
||||
assert result_from_dict(as_dict).selected_candidate == "22011000"
|
||||
# schema fail-closed: a provider answer outside the candidate list is invalid
|
||||
try:
|
||||
result_from_dict({**as_dict, "selected_candidate": "99999999"})
|
||||
raise AssertionError("must reject")
|
||||
except SemanticOutputError:
|
||||
pass
|
||||
# no candidates at all -> NONE, never invented
|
||||
none_result = provider.select(item, [])
|
||||
assert none_result.selected_candidate == "NONE"
|
||||
|
||||
|
||||
def test_budget_guard_blocks_run_over_limit():
|
||||
from central.app.ai.provider import BudgetGuard
|
||||
guard = BudgetGuard(max_run_cost_usd=1.0, price_in_per_m=1.0, price_out_per_m=2.0)
|
||||
plan = guard.estimate(items_in_tokens=600_000, items_out_tokens=300_000)
|
||||
assert plan["estimated_cost"] > 0
|
||||
assert guard.authorize(plan) is False # (600k*1 + 300k*2)/1M = 1.2 > 1.0
|
||||
assert guard.authorize(guard.estimate(100_000, 50_000)) is True
|
||||
|
||||
|
||||
# ---------------- deterministic candidates ----------------
|
||||
|
||||
def test_ncm_top_k_and_ex_tipi_candidates():
|
||||
from central.app.ai.candidates import ncm_top_k, ex_tipi_candidates_for_ncm, cest_candidates_for_ncm
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_ncm_dataset(session, [("22011000", "AGUAS MINERAIS NATURAIS"), ("22019000", "AGUAS MINERAIS GASEIFICADAS OUTRAS"), ("08043000", "ABACAXIS FRESCOS")])
|
||||
top = ncm_top_k(session, "AGUA MINERAL NATURAL", k=2)
|
||||
assert [c[0] for c in top] == ["22011000", "22019000"]
|
||||
dataset = Dataset(kind="TIPI_IPI", provider="RFB", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=2)
|
||||
session.add(dataset); session.flush()
|
||||
session.add(TipiEntry(dataset_id=dataset.id, ncm="33059000", ex=None, description="outros", ipi_rate=None, ipi_rate_text="NT", source_row=1))
|
||||
session.add(TipiEntry(dataset_id=dataset.id, ncm="33059000", ex="01", description="preparacoes para alisar", ipi_rate=None, ipi_rate_text="NT", source_row=2))
|
||||
cest_dataset = Dataset(kind="CEST", provider="CONFAZ", sha256="c" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(cest_dataset); session.flush()
|
||||
cest = CestEntry(dataset_id=cest_dataset.id, code="03.999.99", description="x", annex="II")
|
||||
session.add(cest); session.flush()
|
||||
session.add(CestNcmEntry(cest_entry_id=cest.id, ncm_code="22011000"))
|
||||
session.commit()
|
||||
assert [e.ex for e in ex_tipi_candidates_for_ncm(session, "33059000")] == ["01"]
|
||||
assert [c.code for c in cest_candidates_for_ncm(session, "22011000")] == ["03.999.99"]
|
||||
|
||||
|
||||
def test_kb_persist_roundtrip_models():
|
||||
from central.app.models import KbProduct, KbKnowledge, KbObservation, TenantProductContext, AiWorkItem, AiRun
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
p = KbProduct(fingerprint="f", normalized_description="D")
|
||||
session.add(p); session.flush()
|
||||
session.add(KbKnowledge(kb_product_id=p.id, knowledge_key="candidate_ncm", value="22011000", source="OFFICIAL_DATASET", confidence=1.0))
|
||||
session.add(KbObservation(kb_product_id=p.id, tenant_id=1, company_key="C", erp_code="1", database_label="C", observed_fields={}))
|
||||
session.add(TenantProductContext(tenant_id=1, company_key="C", erp_code="1", kb_product_id=p.id))
|
||||
session.add(AiWorkItem(task_type="CEST", dedup_key="cest:1", payload={}, question_refs=[], cells_covered=1))
|
||||
session.add(AiRun(provider="baseline", model="m", prompt_version="v1", input_tokens=1, output_tokens=1, cost_usd=0.0, status="DONE", results=[]))
|
||||
session.commit()
|
||||
assert session.query(KbProduct).count() == 1 and session.query(AiWorkItem).count() == 1
|
||||
@@ -0,0 +1,18 @@
|
||||
from central.app.operational_state import classify_product_state, summarize_operational_states
|
||||
|
||||
|
||||
def test_twenty_thousand_products_keep_agent_healthy_with_safe_blocks():
|
||||
states = [{"state": "COMPLETE"}] * 19900 + [{"state": "BLOCKED_MISSING_EVIDENCE"}] * 100
|
||||
summary = summarize_operational_states(states)
|
||||
|
||||
assert summary["AUTONOMOUSLY_PROCESSABLE_PRODUCTS"] == 20000
|
||||
assert summary["SAFE_BLOCKED"] == 100
|
||||
assert summary["AGENT_HEALTH"] == "HEALTHY"
|
||||
|
||||
|
||||
def test_new_evidence_changes_product_state_without_global_failure():
|
||||
blocked = classify_product_state({"CODIGO": "P1"}, {"status": "INVALID_NCM"})
|
||||
resolved = classify_product_state({"CODIGO": "P1"}, {"status": "EXACT_NCM"})
|
||||
|
||||
assert blocked["state"] == "BLOCKED_MISSING_EVIDENCE"
|
||||
assert resolved["state"] == "COMPLETE"
|
||||
@@ -0,0 +1,9 @@
|
||||
def test_parser_extracts_item_cest_and_ncm_from_official_table_text():
|
||||
from central.app.datasets.ba_st_mva import parse_page
|
||||
|
||||
records, rejected = parse_page("ITEM CEST NCM/SH DESCRIÇÃO\n3.3 03.003.00 2201.1 Água mineral 114%", 1)
|
||||
|
||||
assert records[0]["item"] == "3.3"
|
||||
assert records[0]["cest"] == "03.003.00"
|
||||
assert records[0]["ncm_sh"] == ["2201.1"]
|
||||
assert not rejected
|
||||
@@ -0,0 +1,68 @@
|
||||
import pytest
|
||||
|
||||
from central.app.ai.batch_runner import run_batches
|
||||
from central.app.ai.fake_provider import DeterministicFakeSemanticProvider
|
||||
from central.app.ai.llm_provider import LlmEvalItem
|
||||
|
||||
|
||||
def _item(work_item_id):
|
||||
return LlmEvalItem(
|
||||
work_item_id=work_item_id,
|
||||
description=work_item_id,
|
||||
normalized_description=work_item_id,
|
||||
brand=None,
|
||||
unit=None,
|
||||
segment="test",
|
||||
candidates=[("A", "candidate A", 1.0), ("B", "candidate B", 0.5)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [5, 10, 20])
|
||||
def test_run_batches_correlates_shuffled_answers_by_id(batch_size):
|
||||
items = [_item(f"w{i}") for i in range(batch_size + 1)]
|
||||
provider = DeterministicFakeSemanticProvider(shuffle=True)
|
||||
|
||||
result = run_batches(items, provider, batch_size=batch_size)
|
||||
|
||||
assert set(result.results_by_work_item_id) == {item.work_item_id for item in items}
|
||||
assert all(result.results_by_work_item_id[item.work_item_id]["work_item_id"] == item.work_item_id for item in items)
|
||||
assert result.errors == {}
|
||||
assert result.provider_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["MISSING_ITEM", "DUPLICATE_ITEM", "INVALID_JSON", "OUT_OF_CANDIDATE"])
|
||||
def test_run_batches_preserves_failed_ids_for_contract_errors(scenario):
|
||||
items = [_item("w1"), _item("w2")]
|
||||
provider = DeterministicFakeSemanticProvider()
|
||||
|
||||
result = run_batches(items, provider, batch_size=2, scenario=scenario)
|
||||
|
||||
assert result.results_by_work_item_id == {}
|
||||
assert set(result.errors) == {"w1", "w2"}
|
||||
assert result.retries == 0
|
||||
assert result.provider_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["TIMEOUT", "429", "500"])
|
||||
def test_run_batches_retries_transport_errors_and_preserves_failed_ids(scenario):
|
||||
items = [_item("w1"), _item("w2")]
|
||||
provider = DeterministicFakeSemanticProvider()
|
||||
|
||||
result = run_batches(items, provider, batch_size=2, max_retries=2, scenario=scenario)
|
||||
|
||||
assert result.results_by_work_item_id == {}
|
||||
assert set(result.errors) == {"w1", "w2"}
|
||||
assert result.provider_calls == 3
|
||||
assert result.retries == 2
|
||||
|
||||
|
||||
def test_run_batches_allows_partial_failure_by_item():
|
||||
items = [_item("w1"), _item("w2"), _item("w3")]
|
||||
provider = DeterministicFakeSemanticProvider(fail_work_item_ids={"w2"}, failure_scenario="TIMEOUT")
|
||||
|
||||
result = run_batches(items, provider, batch_size=1, max_retries=1)
|
||||
|
||||
assert set(result.results_by_work_item_id) == {"w1", "w3"}
|
||||
assert set(result.errors) == {"w2"}
|
||||
assert result.provider_calls == 4
|
||||
assert result.retries == 1
|
||||
@@ -0,0 +1,72 @@
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.blocked_products import list_blockers, resolve_product_blockers, retry_due, upsert_blocker
|
||||
from central.app.models import Agent, Base, BlockedProduct, Installation, Tenant
|
||||
from central.app.operational_state import blocker_fingerprint
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
tenant = Tenant(name="tenant-blocked")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
return session, tenant
|
||||
|
||||
|
||||
def test_same_blocker_is_upserted_not_duplicated():
|
||||
session, tenant = _session()
|
||||
payload = {"tenant": "t", "company": "c", "product_code": "P1", "field": "CFOP", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "reason_code": "MISSING_EVIDENCE", "input_fingerprint": "v1", "evidence_summary": {"source": "ERP"}}
|
||||
|
||||
first = upsert_blocker(session, tenant.id, payload)
|
||||
second = upsert_blocker(session, tenant.id, payload)
|
||||
|
||||
assert first.id == second.id
|
||||
assert session.query(BlockedProduct).count() == 1
|
||||
|
||||
|
||||
def test_new_input_fingerprint_is_due_for_retry():
|
||||
session, tenant = _session()
|
||||
payload = {"tenant": "t", "company": "c", "product_code": "P1", "field": "CFOP", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "reason_code": "MISSING_EVIDENCE", "input_fingerprint": "v1", "evidence_summary": {}}
|
||||
row = upsert_blocker(session, tenant.id, payload)
|
||||
|
||||
assert list_blockers(session, tenant.id, input_fingerprint="v2") == [row]
|
||||
|
||||
|
||||
def test_product_re_evaluation_closes_previous_blocker():
|
||||
session, tenant = _session()
|
||||
row = upsert_blocker(session, tenant.id, {"company": "c", "product_code": "P1", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "v1"})
|
||||
|
||||
assert resolve_product_blockers(session, tenant.id, "P1") == 1
|
||||
assert session.get(BlockedProduct, row.id).resolved_at is not None
|
||||
|
||||
|
||||
def test_same_input_without_retry_schedule_is_cache_hit():
|
||||
session, tenant = _session()
|
||||
row = upsert_blocker(session, tenant.id, {"company": "c", "product_code": "P1", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "v1"})
|
||||
|
||||
assert retry_due(row, "v1") is False
|
||||
assert retry_due(row, "v2") is True
|
||||
|
||||
|
||||
def test_authenticated_installations_get_distinct_fingerprints_for_identical_blockers():
|
||||
session, tenant = _session()
|
||||
agent_a = Agent(tenant_id=tenant.id, name="agent-a", token_hash="agent-a".ljust(64, "x"))
|
||||
agent_b = Agent(tenant_id=tenant.id, name="agent-b", token_hash="agent-b".ljust(64, "x"))
|
||||
session.add_all([agent_a, agent_b])
|
||||
session.flush()
|
||||
installation_a = Installation(agent_id=agent_a.id, tenant_id=tenant.id, installation_id="00000000-0000-4000-8000-000000000201", display_name="Store A")
|
||||
installation_b = Installation(agent_id=agent_b.id, tenant_id=tenant.id, installation_id="00000000-0000-4000-8000-000000000202", display_name="Store B")
|
||||
session.add_all([installation_a, installation_b])
|
||||
session.flush()
|
||||
payload = {"tenant": "t", "company": "c", "product_code": "P1", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "v1"}
|
||||
|
||||
first = upsert_blocker(session, tenant.id, payload, installation_id=installation_a.id)
|
||||
second = upsert_blocker(session, tenant.id, payload, installation_id=installation_b.id)
|
||||
|
||||
assert first.id != second.id
|
||||
assert first.blocker_fingerprint != second.blocker_fingerprint
|
||||
assert blocker_fingerprint("t", "c", "P1", "CFOP", "MISSING", "v1") == blocker_fingerprint("t", "c", "P1", "CFOP", "MISSING", "v1")
|
||||
assert blocker_fingerprint("t", "c", "P1", "CFOP", "MISSING", "v1", installation_id=installation_a.id) != blocker_fingerprint("t", "c", "P1", "CFOP", "MISSING", "v1", installation_id=installation_b.id)
|
||||
@@ -0,0 +1,311 @@
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.blocked_products import upsert_blocker
|
||||
from central.app.blocker_resolution import BlockerResolutionUnavailable, resolve_blocker_with_context, validate_resolution_input
|
||||
from central.app.main import create_app
|
||||
from central.app.models import AdminUser, Agent, AuditEvent, BlockedProduct, FiscalContextEntry, Job, Installation, Tenant
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
|
||||
def _seed_blocker(session_factory):
|
||||
agent_id, admin_id = seed(session_factory)
|
||||
with session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
installation.company_code = "ACME-COMPANY"
|
||||
blocker = upsert_blocker(
|
||||
session,
|
||||
installation.tenant_id,
|
||||
{
|
||||
"company": "Acme",
|
||||
"product_code": "P-001",
|
||||
"description": "Produto de teste",
|
||||
"blocker_type": "BLOCKED_MISSING_EVIDENCE",
|
||||
"field": "CFOP",
|
||||
"reason_code": "MISSING_CONTEXT",
|
||||
"input_fingerprint": "input-1",
|
||||
"evidence_summary": {"source": "test"},
|
||||
},
|
||||
installation_id=installation.id,
|
||||
)
|
||||
return blocker.id, admin_id, installation.id
|
||||
|
||||
|
||||
def test_validate_resolution_input_accepts_known_enum_values():
|
||||
for key, value in (("entry_scope", "INTER"), ("product_role", "RESALE")):
|
||||
normalized, error = validate_resolution_input(
|
||||
{"scope": "PRODUCT", "scope_key": "P-001", "key": key, "value": value, "reason": "operator note"}
|
||||
)
|
||||
assert error is None
|
||||
assert normalized == {
|
||||
"scope": "PRODUCT",
|
||||
"scope_key": "P-001",
|
||||
"key": key,
|
||||
"value": value,
|
||||
"reason": "operator note",
|
||||
}
|
||||
|
||||
|
||||
def test_validate_resolution_input_rejects_unsafe_or_incomplete_values():
|
||||
invalid_payloads = (
|
||||
{"scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "ARBITRARY", "reason": "note"},
|
||||
{"scope": "PRODUCT", "scope_key": "P-001", "key": "not-a-key", "value": "x", "reason": "note"},
|
||||
{"scope": "NCM", "scope_key": "22030000", "key": "entry_scope", "value": "INTER", "reason": "note"},
|
||||
{"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": ""},
|
||||
{"scope": "PRODUCT", "key": "product_role", "value": "RESALE", "reason": "note"},
|
||||
{"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "note", "context": "free text"},
|
||||
)
|
||||
|
||||
for payload in invalid_payloads:
|
||||
normalized, error = validate_resolution_input(payload)
|
||||
assert normalized is None
|
||||
assert error
|
||||
|
||||
|
||||
def test_resolve_blocker_with_context_persists_one_context_row_job_and_sanitized_audit():
|
||||
session_factory = setup_db()
|
||||
blocker_id, admin_id, installation_id = _seed_blocker(session_factory)
|
||||
|
||||
with session_factory() as session:
|
||||
admin = session.get(AdminUser, admin_id)
|
||||
result = resolve_blocker_with_context(
|
||||
session,
|
||||
blocker_id,
|
||||
admin,
|
||||
{
|
||||
"scope": "PRODUCT",
|
||||
"scope_key": "P-001",
|
||||
"key": "product_role",
|
||||
"value": "RESALE",
|
||||
"reason": "reviewed token=secret-value",
|
||||
},
|
||||
request_ip="192.0.2.10",
|
||||
)
|
||||
|
||||
entry = session.scalar(select(FiscalContextEntry).where(FiscalContextEntry.key == "product_role"))
|
||||
job = session.get(Job, result["job_id"])
|
||||
audit = session.get(AuditEvent, result["audit_id"])
|
||||
|
||||
assert result["blocker_id"] == blocker_id
|
||||
assert entry.company_key == "ACME-COMPANY"
|
||||
assert entry.scope == "PRODUCT"
|
||||
assert entry.scope_key == "P-001"
|
||||
assert entry.value == "RESALE"
|
||||
assert entry.source == "OPERATOR"
|
||||
assert job.agent_id == session.get(Installation, installation_id).agent_id
|
||||
assert job.kind == "REPROCESS_PRODUCTS"
|
||||
assert job.payload == {"codigos": ["P-001"]}
|
||||
assert audit.action == "RESOLVE_BLOCKER_WITH_CONTEXT"
|
||||
assert audit.target_type == "blocker"
|
||||
assert audit.target_id == str(blocker_id)
|
||||
assert audit.context["reprocess_job_id"] == job.id
|
||||
assert audit.context["before"] is None
|
||||
assert audit.context["after"]["value"] == "RESALE"
|
||||
assert audit.context["request_ip"] == "192.0.2.10"
|
||||
assert "secret-value" not in str(audit.context)
|
||||
assert session.query(FiscalContextEntry).count() == 1
|
||||
|
||||
|
||||
def test_legacy_blocker_without_installation_fails_closed_without_job_or_audit():
|
||||
session_factory = setup_db()
|
||||
blocker_id, admin_id, _ = _seed_blocker(session_factory)
|
||||
|
||||
with session_factory() as session:
|
||||
blocker = session.get(BlockedProduct, blocker_id)
|
||||
blocker.installation_id = None
|
||||
session.commit()
|
||||
admin = session.get(AdminUser, admin_id)
|
||||
|
||||
with pytest.raises(BlockerResolutionUnavailable, match="Blocker legado"):
|
||||
resolve_blocker_with_context(session, blocker_id, admin, {"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"})
|
||||
|
||||
assert session.query(FiscalContextEntry).count() == 0
|
||||
assert session.query(Job).count() == 0
|
||||
assert session.query(AuditEvent).count() == 0
|
||||
|
||||
|
||||
def test_resolution_rejects_company_key_over_database_limit_before_mutation():
|
||||
session_factory = setup_db()
|
||||
blocker_id, admin_id, installation_id = _seed_blocker(session_factory)
|
||||
|
||||
with session_factory() as session:
|
||||
installation = session.get(Installation, installation_id)
|
||||
installation.company_code = "C" * 65
|
||||
session.commit()
|
||||
admin = session.get(AdminUser, admin_id)
|
||||
|
||||
with pytest.raises(ValueError, match="company key.*64"):
|
||||
resolve_blocker_with_context(
|
||||
session,
|
||||
blocker_id,
|
||||
admin,
|
||||
{"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"},
|
||||
)
|
||||
|
||||
assert session.query(FiscalContextEntry).count() == 0
|
||||
assert session.query(Job).count() == 0
|
||||
assert session.query(AuditEvent).count() == 0
|
||||
|
||||
|
||||
def test_cross_tenant_installation_pointer_fails_before_mutation():
|
||||
session_factory = setup_db()
|
||||
blocker_id, admin_id, _ = _seed_blocker(session_factory)
|
||||
|
||||
with session_factory() as session:
|
||||
blocker = session.get(BlockedProduct, blocker_id)
|
||||
other_tenant = Tenant(name="Other Client")
|
||||
session.add(other_tenant)
|
||||
session.flush()
|
||||
other_agent = Agent(tenant_id=other_tenant.id, name="other-agent", token_hash="other-agent-token".ljust(64, "x"))
|
||||
session.add(other_agent)
|
||||
session.flush()
|
||||
other_installation = Installation(agent_id=other_agent.id, tenant_id=other_tenant.id, installation_id="22222222-2222-4222-8222-222222222222", company_code="OTHER-COMPANY")
|
||||
session.add(other_installation)
|
||||
session.flush()
|
||||
blocker.installation_id = other_installation.id
|
||||
session.commit()
|
||||
admin = session.get(AdminUser, admin_id)
|
||||
|
||||
with pytest.raises(LookupError, match="tenant"):
|
||||
resolve_blocker_with_context(session, blocker_id, admin, {"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"})
|
||||
|
||||
assert session.query(FiscalContextEntry).count() == 0
|
||||
assert session.query(Job).count() == 0
|
||||
assert session.query(AuditEvent).count() == 0
|
||||
|
||||
|
||||
def test_resolution_input_rejects_oversized_and_non_string_fields_cleanly():
|
||||
base = {"scope": "PRODUCT", "scope_key": "P-001", "key": "service_cfop", "value": "5102", "reason": "reviewed"}
|
||||
for field, value in (("value", "v" * 513), ("reason", "r" * 2001), ("reason", 123)):
|
||||
payload = {**base, field: value}
|
||||
normalized, error = validate_resolution_input(payload)
|
||||
assert normalized is None
|
||||
assert error
|
||||
|
||||
|
||||
def test_resolution_input_rejects_multibyte_utf8_at_ascii_character_limits():
|
||||
base = {"scope": "PRODUCT", "scope_key": "P-001", "key": "service_cfop", "value": "5102", "reason": "reviewed"}
|
||||
for field, value in (("value", "é" * 512), ("reason", "é" * 2000)):
|
||||
normalized, error = validate_resolution_input({**base, field: value})
|
||||
assert normalized is None
|
||||
assert error and "bytes" in error
|
||||
|
||||
|
||||
def test_boundary_length_audit_keeps_required_fields_instead_of_truncating_context():
|
||||
session_factory = setup_db()
|
||||
blocker_id, admin_id, _ = _seed_blocker(session_factory)
|
||||
|
||||
with session_factory() as session:
|
||||
admin = session.get(AdminUser, admin_id)
|
||||
result = resolve_blocker_with_context(
|
||||
session,
|
||||
blocker_id,
|
||||
admin,
|
||||
{"scope": "PRODUCT", "scope_key": "P-001", "key": "service_cfop", "value": "v" * 512, "reason": "r" * 2000},
|
||||
)
|
||||
audit = session.get(AuditEvent, result["audit_id"])
|
||||
required = {"admin_user_id", "tenant_id", "client", "company_key", "installation_id", "product_code", "blocker_id", "key", "scope", "scope_key", "value", "reason", "before", "after", "reprocess_job_id", "request_ip"}
|
||||
assert required <= audit.context.keys()
|
||||
assert "truncated" not in audit.context
|
||||
|
||||
|
||||
def test_human_resolution_detail_requires_auth_csrf_and_renders_safe_form():
|
||||
session_factory = setup_db()
|
||||
blocker_id, _, _ = _seed_blocker(session_factory)
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
|
||||
|
||||
assert client.get(f"/blockers/{blocker_id}", follow_redirects=False).status_code == 303
|
||||
client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
detail = client.get(f"/blockers/{blocker_id}")
|
||||
assert detail.status_code == 200
|
||||
assert "Salvar contexto e reprocessar" in detail.text
|
||||
assert "Cliente" in detail.text and "Servidor" in detail.text and "Produto" in detail.text
|
||||
assert "Evidência" in detail.text and "Valores aceitos" in detail.text
|
||||
assert "data-confirm" not in detail.text
|
||||
assert 'name="context"' not in detail.text
|
||||
|
||||
missing_csrf = client.post(
|
||||
f"/blockers/{blocker_id}/resolve-context",
|
||||
data={"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"},
|
||||
)
|
||||
assert missing_csrf.status_code == 403
|
||||
|
||||
success = client.post(
|
||||
f"/blockers/{blocker_id}/resolve-context",
|
||||
data={"csrf_token": client.cookies.get("gti_csrf"), "scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert success.status_code == 303
|
||||
assert success.headers["location"] == f"/blockers/{blocker_id}"
|
||||
|
||||
|
||||
def test_human_and_json_resolution_routes_use_the_same_validated_workflow():
|
||||
session_factory = setup_db()
|
||||
blocker_id, _, _ = _seed_blocker(session_factory)
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
|
||||
client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
csrf = client.cookies.get("gti_csrf")
|
||||
|
||||
invalid = client.post(
|
||||
f"/v1/blockers/{blocker_id}/resolve-human",
|
||||
json={"csrf_token": csrf, "scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "free text", "reason": "reviewed"},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
|
||||
api_result = client.post(
|
||||
f"/v1/blockers/{blocker_id}/resolve-human",
|
||||
json={"csrf_token": csrf, "scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "INTER", "reason": "reviewed"},
|
||||
)
|
||||
assert api_result.status_code == 200
|
||||
assert set(api_result.json()) == {"blocker_id", "job_id", "audit_id"}
|
||||
|
||||
unauthenticated = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)).post(
|
||||
f"/v1/blockers/{blocker_id}/resolve-human",
|
||||
json={"scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "INTER", "reason": "reviewed"},
|
||||
)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
|
||||
def test_unbound_blocker_resolution_routes_return_conflict_but_missing_stays_not_found():
|
||||
session_factory = setup_db()
|
||||
blocker_id, _, _ = _seed_blocker(session_factory)
|
||||
with session_factory() as session:
|
||||
session.get(BlockedProduct, blocker_id).installation_id = None
|
||||
session.commit()
|
||||
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
|
||||
client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
payload = {"csrf_token": client.cookies.get("gti_csrf"), "scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"}
|
||||
|
||||
for route in (f"/blockers/{blocker_id}/resolve-context", f"/v1/blockers/{blocker_id}/resolve-human"):
|
||||
response = client.post(route, data=payload) if route.startswith("/blockers/") else client.post(route, json=payload)
|
||||
assert response.status_code == 409
|
||||
assert "sem vínculo comprovado" in response.text.lower()
|
||||
|
||||
missing = client.post("/v1/blockers/999999/resolve-human", json=payload)
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_invalid_resolution_rerenders_submitted_values_and_persisted_context():
|
||||
session_factory = setup_db()
|
||||
blocker_id, _, _ = _seed_blocker(session_factory)
|
||||
with session_factory() as session:
|
||||
blocker = session.get(BlockedProduct, blocker_id)
|
||||
session.add(FiscalContextEntry(tenant_id=blocker.tenant_id, company_key="ACME-COMPANY", scope="PRODUCT", scope_key="P-001", key="entry_scope", value="INTER", source="OPERATOR", reason="already saved"))
|
||||
session.commit()
|
||||
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
|
||||
client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
response = client.post(
|
||||
f"/blockers/{blocker_id}/resolve-context",
|
||||
data={"csrf_token": client.cookies.get("gti_csrf"), "key": "product_role", "value": "NOT_VALID", "scope": "COMPANY", "scope_key": "ACME", "reason": "submitted reason"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert 'option value="product_role" selected' in response.text
|
||||
assert 'name="value" required maxlength="512" value="NOT_VALID"' in response.text
|
||||
assert 'option value="COMPANY" selected' in response.text
|
||||
assert 'name="scope_key" value="ACME"' in response.text
|
||||
assert "submitted reason" in response.text
|
||||
assert "entry_scope" in response.text and "INTER" in response.text
|
||||
@@ -0,0 +1,113 @@
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.datasets import bootstrap_v1_extra as parsers
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base
|
||||
|
||||
ROOT = Path(__file__).parents[2] / "sources" / "raw" / "bootstrap_v1"
|
||||
FILES = {
|
||||
"cfop": ROOT / "IT_2023_002_v2_10_Tabela_CFOP_Descrição.xlsx",
|
||||
"fcp": ROOT / "Tabela_Alíquotas_FCP_por_UF_2025-02_07.xlsx",
|
||||
"anp": ROOT / "Tabela_Codigo_Produto_ANP-20241118.xlsx",
|
||||
"ncm_utrib": ROOT / "IT2024.001v2.40 - Tabela NCM 2022 com Utrib_Comércio Exterior_vigência 01.10.26.xlsx",
|
||||
"biofuel": ROOT / "IT 2025.004 v.1.20 - Tabela de Índice.xlsx",
|
||||
"credpres": ROOT / "cCredPres_2026-06-22.xlsx",
|
||||
}
|
||||
pytestmark = pytest.mark.skipif(not all(p.exists() for p in FILES.values()), reason="bootstrap_v1 extra source files not present locally")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _payload(name):
|
||||
parse_fn = {"cfop": parsers.parse_cfop, "fcp": parsers.parse_fcp, "anp": parsers.parse_anp,
|
||||
"ncm_utrib": parsers.parse_ncm_utrib, "biofuel": parsers.parse_rtc_biofuel_index,
|
||||
"credpres": parsers.parse_rtc_cred_pres}[name]
|
||||
return parse_fn(FILES[name].read_bytes())
|
||||
|
||||
|
||||
def _engine():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def test_cfop_parses_full_table_with_official_fields():
|
||||
payload = _payload("cfop")
|
||||
assert len(payload.entries) >= 500
|
||||
sample = payload.entries[0]
|
||||
assert set(sample) >= {"code", "title", "description", "effective_from", "ind_nfe", "ind_comb"}
|
||||
|
||||
|
||||
def test_fcp_covers_all_states_and_df():
|
||||
payload = _payload("fcp")
|
||||
ufs = {e["uf"] for e in payload.entries}
|
||||
assert len(ufs) == 27 # 26 states + DF
|
||||
assert "BA" in ufs
|
||||
|
||||
|
||||
def test_anp_has_no_invented_descriptions():
|
||||
payload = _payload("anp")
|
||||
assert len(payload.entries) >= 900
|
||||
assert all(set(e) == {"cprod_anp"} for e in payload.entries) # source truly has no description column
|
||||
|
||||
|
||||
def test_ncm_utrib_covers_thousands_of_ncms():
|
||||
payload = _payload("ncm_utrib")
|
||||
assert len(payload.entries) >= 10000
|
||||
assert all(len(e["ncm"]) == 8 for e in payload.entries[:50])
|
||||
|
||||
|
||||
def test_biofuel_index_separates_current_from_historical():
|
||||
payload = _payload("biofuel")
|
||||
current = [e for e in payload.entries if not e["is_historical"]]
|
||||
historical = [e for e in payload.entries if e["is_historical"]]
|
||||
assert current and historical
|
||||
assert all(e["p_bio_percent"] is not None for e in current)
|
||||
|
||||
|
||||
def test_credpres_preserves_rich_official_fields_not_just_code_description():
|
||||
payload = _payload("credpres")
|
||||
sample = payload.entries[0]
|
||||
assert sample["legal_basis"]
|
||||
assert "d_ini_vig_cbs" in sample and "aliquota_cbs_text" in sample
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain,method,lookup", [
|
||||
("cfop", "import_cfop", lambda s: s.cfop_lookup(code="1101")),
|
||||
("fcp", "import_fcp", lambda s: [s.fcp_for_uf("BA")]),
|
||||
("anp", "import_anp", lambda s: [s.anp_code_exists("110203073")]),
|
||||
("ncm_utrib", "import_ncm_utrib", lambda s: [s.ncm_utrib_for_ncm("01012100")]),
|
||||
("biofuel", "import_rtc_biofuel_index", lambda s: s.rtc_biofuel_for_anp("320102001")),
|
||||
("credpres", "import_rtc_cred_pres", lambda s: s.rtc_cred_pres(code="1")),
|
||||
])
|
||||
def test_import_idempotent_and_lookup_smoke(domain, method, lookup):
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
payload = _payload(domain)
|
||||
first = getattr(service, method)(payload, f"file://{domain}", datetime.now(timezone.utc))
|
||||
second = getattr(service, method)(payload, f"file://{domain}", datetime.now(timezone.utc))
|
||||
assert first.id == second.id
|
||||
assert second.status == "ACTIVE"
|
||||
assert second.record_count == len(payload.entries)
|
||||
|
||||
result = lookup(service)
|
||||
assert result
|
||||
assert all(item is not None and item is not False for item in result) or result == [True]
|
||||
|
||||
|
||||
def test_dataset_versions_includes_all_new_kinds():
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
versions = service.dataset_versions()
|
||||
for key in ("cfop", "fcp", "anp", "ncm_utrib", "rtc_biofuel_index", "rtc_ccredpres"):
|
||||
assert key in versions
|
||||
assert versions[key] is None
|
||||
service.import_cfop(_payload("cfop"), "file://cfop", datetime.now(timezone.utc))
|
||||
assert service.dataset_versions()["cfop"] is not None
|
||||
@@ -0,0 +1,153 @@
|
||||
from datetime import date
|
||||
|
||||
from central.app import rtc_reference_data as ref
|
||||
from central.app.cclasstrib_resolver import (ANNEX_DEFAULT_CLASS, annex_matches, compare_catalogs, local_catalog_hash, resolve_cclasstrib, verify_annex_class_map)
|
||||
|
||||
TODAY = date(2026, 9, 24)
|
||||
NORMAL, SIMPLES = {"crt": "3"}, {"crt": "1"}
|
||||
ON = {"rtc_enabled": True, "local_catalog_valid": True}
|
||||
|
||||
|
||||
def local_from_official(skip=(), tweak=None):
|
||||
rows = {}
|
||||
for r in ref.OFFICIAL_CCLASSTRIB:
|
||||
if r["code"] in skip:
|
||||
continue
|
||||
rows[r["code"]] = {"cst": r["cst"], "tipo_aliquota": r["tipo_aliquota"], "p_red_ibs": r["p_red_ibs"], "p_red_cbs": r["p_red_cbs"], "ind_nfe": r["ind_nfe"], "ind_nfce": r["ind_nfce"], "start": r["start"], "end": r["end"]}
|
||||
for code, change in (tweak or {}).items():
|
||||
rows[code] = {**rows[code], **change}
|
||||
return rows
|
||||
|
||||
|
||||
def product(ncm, valid=True, code="1"):
|
||||
return {"CODIGO": code, "CLASSIFICACAO_FISCAL": ncm, "ncm_valid": valid}
|
||||
|
||||
|
||||
def resolve(ncm, company=NORMAL, ctx=ON, local=None, valid=True):
|
||||
return resolve_cclasstrib(product(ncm, valid), company, ctx, local if local is not None else local_from_official(), today=TODAY)
|
||||
|
||||
|
||||
def test_annex_default_class_map_is_verified_against_the_official_table():
|
||||
assert verify_annex_class_map() == {annex: [] for annex in ANNEX_DEFAULT_CLASS}
|
||||
|
||||
|
||||
def test_reference_data_carries_provenance_and_the_expected_shape():
|
||||
assert len(ref.OFFICIAL_CCLASSTRIB) == 164 and ref.OFFICIAL_CATALOG["sha256"] and ref.LC214_SOURCE["sha256"] and ref.LC214_SOURCE["accessed_at"]
|
||||
assert {"I", "VII", "VIII", "IX", "XV"} <= set(ref.LC214_ANNEXES) and ref.LC214_ANNEXES["XIV"]["revoked"] is True
|
||||
assert len(ref.LC214_ANNEXES["I"]["items"]) == 26 and len(ref.LC214_ANNEXES["XV"]["items"]) == 6
|
||||
|
||||
|
||||
def test_rtc_off_is_not_applicable_and_never_a_candidate():
|
||||
r = resolve("10063021", ctx={"rtc_enabled": False, "local_catalog_valid": True})
|
||||
assert r.status == "NOT_APPLICABLE" and r.rule_id == "RTC-R0" and not r.auto_apply_candidate
|
||||
|
||||
|
||||
def test_simples_is_insufficient_until_the_official_orientation_exists():
|
||||
r = resolve("10063021", company=SIMPLES)
|
||||
assert r.status == "INSUFFICIENT_EVIDENCE" and r.rule_id == "RTC-R1" and not r.auto_apply_candidate and r.source_references[-1]["document"].startswith("Ato Conjunto RFB/CGIBS") and r.rule_class == "BLOCKED_BY_EVIDENCE"
|
||||
assert resolve("10063021", company={"crt": None}).reason == "COMPANY_REGIME_UNKNOWN"
|
||||
|
||||
|
||||
def test_invalid_or_unconfirmed_ncm_is_insufficient():
|
||||
assert resolve("1234", valid=True).reason == "NO_VALID_NCM"
|
||||
assert resolve("10063021", valid=False).reason == "NO_VALID_NCM"
|
||||
|
||||
|
||||
def test_clean_annex_item_resolves_to_the_annex_default_class_with_evidence():
|
||||
r = resolve("10063021") # rice: Anexo I item 1 (subposicao 1006.30)
|
||||
assert r.status == "RESOLVED_WITH_EXPLICIT_RULE" and r.resolved_cclasstrib == "200003" and r.resolved_cst_ibscbs == "200" and r.rule_id == "RTC-R3" and r.auto_apply_candidate
|
||||
assert r.evidence[0]["annex"] == "I" and r.evidence[0]["clean"] is True and r.confidence_type == "ANNEX_TEXT_MATCH"
|
||||
assert r.source_references[0]["sha256"] == ref.LC214_SOURCE["sha256"] and r.inputs_used["ncm"] == "10063021"
|
||||
|
||||
|
||||
def test_conditional_annex_item_is_ambiguous_not_guessed():
|
||||
milk = next(i for i in ref.LC214_ANNEXES["I"]["items"] if not i["clean"] and i["prefixes"])
|
||||
ncm = milk["prefixes"][0].ljust(8, "0")
|
||||
r = resolve(ncm)
|
||||
assert r.status == "AMBIGUOUS" and r.rule_id == "RTC-A1" and not r.auto_apply_candidate and r.evidence[0]["clean"] is False
|
||||
|
||||
|
||||
def test_annex_without_a_unique_default_class_is_ambiguous():
|
||||
item = next(i for i in ref.LC214_ANNEXES["IV"]["items"] if i["clean"]) # medical devices: 60% class vs zero-rated public-buyer class
|
||||
r = resolve(item["prefixes"][0].ljust(8, "1"))
|
||||
assert r.status == "AMBIGUOUS" and "NEEDS_OPERATION_OR_CONDITION" in r.reason
|
||||
|
||||
|
||||
def test_revoked_annex_only_is_ambiguous():
|
||||
prefix = next(p for i in ref.LC214_ANNEXES["XIV"]["items"] for p in i["prefixes"] if len(p) == 8 and not annex_matches_other_than_xiv(p))
|
||||
assert resolve(prefix).rule_id == "RTC-A1" and resolve(prefix).reason == "LISTED_ONLY_IN_REVOKED_ANNEX"
|
||||
|
||||
|
||||
def annex_matches_other_than_xiv(prefix):
|
||||
return [h for h in annex_matches(prefix) if h["annex"] != "XIV"]
|
||||
|
||||
|
||||
def test_nature_based_chapters_are_ambiguous():
|
||||
for ncm in ("03028990", "30049099", "27101259", "49019900", "87032100", "90183100"):
|
||||
r = resolve(ncm)
|
||||
assert r.status == "AMBIGUOUS" and r.rule_id in ("RTC-A3", "RTC-A1", "RTC-A2"), ncm
|
||||
|
||||
|
||||
def test_residual_ncm_outside_every_annex_is_only_a_suggestion_and_never_a_write_candidate():
|
||||
r = resolve("39235000") # plastic closures: no annex, no nature-based regime
|
||||
assert r.status == "SUGGESTION_ONLY" and r.resolved_cclasstrib == "000001" and r.suggested_cclasstrib == "000001" and r.rule_id == "RTC-R4" and r.rule_class == "SUGGESTION_ONLY"
|
||||
assert r.auto_apply_candidate is False and annex_matches("39235000") == [] and r.confidence_type == "EXCLUSION_OF_ALL_SPECIAL_REGIMES"
|
||||
assert "not positive evidence" in r.evidence[0]["note"] and r.as_dict()["auto_apply_candidate"] is False
|
||||
|
||||
|
||||
def test_only_auto_apply_safe_rules_can_authorize_a_write():
|
||||
from central.app.cclasstrib_resolver import RULE_CLASSES
|
||||
assert [rid for rid, cls in RULE_CLASSES.items() if cls == "AUTO_APPLY_SAFE"] == ["RTC-R3"]
|
||||
assert RULE_CLASSES["RTC-R4"] == "SUGGESTION_ONLY" and RULE_CLASSES["RTC-R0"] == "VALIDATION_ONLY"
|
||||
assert {c for c in RULE_CLASSES.values()} == {"AUTO_APPLY_SAFE", "VALIDATION_ONLY", "SUGGESTION_ONLY", "BLOCKED_BY_EVIDENCE"}
|
||||
|
||||
|
||||
def test_regime_other_than_normal_never_reaches_a_positive_rule():
|
||||
for company in ({"crt": "2"}, {"crt": "4"}, {"crt": "9"}, {"crt": ""}):
|
||||
assert not resolve("10063021", company=company).auto_apply_candidate
|
||||
|
||||
|
||||
def test_a_class_missing_or_different_in_the_local_catalog_is_never_proposed():
|
||||
r = resolve("10063021", local=local_from_official(skip=("200003",)))
|
||||
assert r.status == "INSUFFICIENT_EVIDENCE" and r.reason == "CLASS_NOT_IN_LOCAL_CATALOG" and not r.auto_apply_candidate
|
||||
r2 = resolve("10063021", local=local_from_official(tweak={"200003": {"cst": "010"}}))
|
||||
assert r2.reason == "LOCAL_OFFICIAL_MISMATCH" and not r2.auto_apply_candidate
|
||||
r3 = resolve("10063021", local=local_from_official(tweak={"200003": {"ind_nfe": False, "ind_nfce": False}}))
|
||||
assert r3.reason == "LOCAL_OFFICIAL_MISMATCH"
|
||||
|
||||
|
||||
def test_invalid_local_catalog_blocks_every_proposal():
|
||||
assert resolve("10063021", ctx={"rtc_enabled": True, "local_catalog_valid": False}).reason == "LOCAL_RTC_CATALOG_INVALID"
|
||||
|
||||
|
||||
def test_catalog_comparison_classifies_every_code_and_flags_material_problems():
|
||||
full = compare_catalogs(local_from_official(), TODAY)
|
||||
assert full["counts"]["MATCH_SAFE"] == 161 and full["counts"]["EXPIRED"] == 3 and full["compatibility"] == "PARTIAL_LOCAL_OUTDATED" # 220001-3 ended on 2026-01-01
|
||||
assert compare_catalogs(local_from_official(skip=("220001", "220002", "220003")), TODAY)["compatibility"] == "FULL"
|
||||
outdated = compare_catalogs(local_from_official(skip=("000005", "200053")), TODAY)
|
||||
assert outdated["counts"]["MISSING_LOCAL"] == 2 and outdated["compatibility"] == "PARTIAL_LOCAL_OUTDATED" and outdated["counts"]["MISMATCH"] == 0
|
||||
local = local_from_official(tweak={"200003": {"p_red_ibs": "60"}})
|
||||
local["999999"] = {"cst": "000", "p_red_ibs": "0", "p_red_cbs": "0", "ind_nfe": True, "ind_nfce": True}
|
||||
bad = compare_catalogs(local, TODAY)
|
||||
assert bad["per_code"]["200003"] == "MISMATCH" and bad["per_code"]["999999"] == "MISSING_OFFICIAL" and bad["compatibility"] == "MATERIAL_MISMATCH"
|
||||
|
||||
|
||||
def test_non_material_differences_are_classified_but_not_treated_as_a_mismatch():
|
||||
earlier = compare_catalogs(local_from_official(tweak={"200003": {"start": "2025-05-05"}}), TODAY) # SGV imports an earlier start: both in force today => same emission effect
|
||||
assert earlier["per_code"]["200003"] == "MATCH_SAFE" and earlier["details"]["200003"] == ["VIGENCY_START_DIFFERS_BOTH_IN_FORCE"]
|
||||
assert compare_catalogs(local_from_official(tweak={"200003": {"start": "2027-01-01"}}), TODAY)["per_code"]["200003"] == "MISMATCH"
|
||||
assert compare_catalogs(local_from_official(tweak={"200003": {"end": "2026-01-01"}}), TODAY)["per_code"]["200003"] == "MISMATCH"
|
||||
local = local_from_official(tweak={"200003": {"end": "2027-12-31"}}) # a scheduled local end the official table does not have
|
||||
c = compare_catalogs(local, TODAY)
|
||||
assert c["per_code"]["200003"] == "MATCH_WITH_NON_MATERIAL_DIFFERENCE" and c["counts"]["MISMATCH"] == 0
|
||||
assert resolve("10063021", local=local).status == "INSUFFICIENT_EVIDENCE" # auto-apply requires MATCH_SAFE: a non-material difference still blocks the WRITE
|
||||
|
||||
|
||||
def test_local_catalog_hash_is_stable_and_sensitive():
|
||||
a, b = local_from_official(), local_from_official(tweak={"200003": {"p_red_ibs": "60"}})
|
||||
assert local_catalog_hash(a) == local_catalog_hash(local_from_official()) and local_catalog_hash(a) != local_catalog_hash(b)
|
||||
|
||||
|
||||
def test_resolution_is_deterministic_and_auditable():
|
||||
a, b = resolve("10063021"), resolve("10063021")
|
||||
assert a.as_dict() == b.as_dict() and {"rule_id", "inputs_used", "source_references", "evidence", "reason"} <= set(a.as_dict())
|
||||
@@ -0,0 +1,38 @@
|
||||
from central.app.classification import Classifier
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self, cest_entries):
|
||||
self._cest_entries = cest_entries
|
||||
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "19022000", "description": "Massas"})()]
|
||||
|
||||
def cest_for_ncm(self, code):
|
||||
return self._cest_entries if code == "19022000" else []
|
||||
|
||||
def legacy_ncm(self, code):
|
||||
return None
|
||||
|
||||
def dataset_versions(self):
|
||||
return {"ncm": "n", "cest": "c"}
|
||||
|
||||
|
||||
def test_stopwords_do_not_inflate_description_score():
|
||||
# "RISOLE DE CAMARAO" vs an official text that only shares the stopword "DE"
|
||||
# ("... preparadas de outro modo") must score 0, not a false-positive overlap.
|
||||
cest = type("Cest", (), {
|
||||
"code": "17.048.02",
|
||||
"description": "Massas alimenticias recheadas (mesmo cozidas ou preparadas de outro modo)",
|
||||
})()
|
||||
result = Classifier(_Repository([cest])).classify({"current_ncm": "19022000", "description": "RISOLE DE CAMARAO"})
|
||||
candidate = result["classification"]["cest_candidates"][0]
|
||||
assert candidate["description_score"] == 0.0
|
||||
assert result["cest_status"] == "CEST_DESCRIPTION_MISMATCH"
|
||||
|
||||
|
||||
def test_genuine_token_overlap_still_scores_above_zero():
|
||||
cest = type("Cest", (), {"code": "17.024.01", "description": "Queijo mucarela"})()
|
||||
result = Classifier(_Repository([cest])).classify({"current_ncm": "19022000", "description": "BOLINHO DE QUEIJO"})
|
||||
candidate = result["classification"]["cest_candidates"][0]
|
||||
assert candidate["description_score"] > 0
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Missão 4 / Bloco E.1-E.2: Hub-side plumbing for the CFOP_IPPT_WRITE_V1 capability - per-installation
|
||||
mode (ADMIN-only, CSRF, audited, DEFAULT OFF), delivered inside the authenticated job, and the
|
||||
append-only audit trail recorded when the job completes. Mirrors central/tests/test_rtc_security.py's
|
||||
security posture for a separate, narrower capability."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import AuditEvent, CfopIpptWriteAudit, Installation
|
||||
from central.tests.test_control_panel_pages import _panel_client
|
||||
from central.tests.test_rtc_security import _become, _post
|
||||
from central.tests.test_zero_touch_hub import H, OPERATIONAL, _client, finish, hb
|
||||
|
||||
|
||||
def test_default_mode_is_disabled_everywhere():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(Installation)).one().cfop_ippt_write_mode == "DISABLED"
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
assert job["authorizations"]["cfop_ippt_write_mode"] == "DISABLED"
|
||||
body = client.get("/v1/agents/me/authorizations", headers=H).json()
|
||||
assert body["cfop_ippt_write_mode"] == "DISABLED"
|
||||
|
||||
|
||||
def test_a_forged_completion_payload_cannot_change_the_mode_or_fabricate_an_audit_row():
|
||||
"""Flag OFF => zero jobs carry write permission, and nothing the Agent sends back can forge one -
|
||||
the mode is read fresh from the Installation row, never from the job payload."""
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
forged = {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [],
|
||||
"authorizations": {"cfop_ippt_write_mode": "ENABLED"},
|
||||
"cfop_ippt_apply": {"applied": [{"product_code": "X", "field": "CFOP", "outcome": "APPLIED", "old_value": None, "new_value": "5102"}]}}
|
||||
client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": forged}, headers=H)
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(Installation)).one().cfop_ippt_write_mode == "DISABLED"
|
||||
# the audit row IS stored (it's just evidence of what the Agent reported for that job) but the
|
||||
# mode itself never moved - a forged "ENABLED" in the payload changes nothing authoritative.
|
||||
row = s.scalars(select(CfopIpptWriteAudit)).one()
|
||||
assert row.outcome == "APPLIED" and row.field == "CFOP" and row.new_value == "5102"
|
||||
|
||||
|
||||
def test_write_mode_is_restricted_to_gerenciador_admins_and_every_denial_is_audited():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "PADRAO")
|
||||
assert _post(client, f"/agents/{agent_id}/cfop-ippt-write", mode="ENABLED", confirm="GRAVAR").status_code == 403
|
||||
with client._session_factory() as s:
|
||||
acts = [e.action for e in s.scalars(select(AuditEvent))]
|
||||
assert "CFOP_IPPT_WRITE_MODE" not in acts
|
||||
|
||||
|
||||
def test_admin_can_confirm_a_write_mode_and_it_is_audited():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
assert client.post(f"/agents/{agent_id}/cfop-ippt-write", data={"csrf_token": "bad", "mode": "ENABLED", "confirm": "GRAVAR"}, follow_redirects=False).status_code in (400, 403)
|
||||
resp = _post(client, f"/agents/{agent_id}/cfop-ippt-write", mode="ENABLED", confirm="GRAVAR")
|
||||
assert resp.status_code == 303
|
||||
with client._session_factory() as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
assert inst.cfop_ippt_write_mode == "ENABLED"
|
||||
acts = [e.action for e in s.scalars(select(AuditEvent))]
|
||||
assert "CFOP_IPPT_WRITE_MODE" in acts
|
||||
|
||||
|
||||
def test_enabling_without_typing_gravar_is_refused():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
resp = _post(client, f"/agents/{agent_id}/cfop-ippt-write", mode="ENABLED")
|
||||
assert resp.status_code == 422
|
||||
with client._session_factory() as s:
|
||||
assert s.scalars(select(Installation)).one().cfop_ippt_write_mode == "DISABLED"
|
||||
|
||||
|
||||
def test_disabling_never_requires_the_gravar_confirmation():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
assert _post(client, f"/agents/{agent_id}/cfop-ippt-write", mode="ENABLED", confirm="GRAVAR").status_code == 303
|
||||
assert _post(client, f"/agents/{agent_id}/cfop-ippt-write", mode="DISABLED").status_code == 303
|
||||
with client._session_factory() as s:
|
||||
assert s.scalars(select(Installation)).one().cfop_ippt_write_mode == "DISABLED"
|
||||
|
||||
|
||||
def test_invalid_mode_is_rejected():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
resp = _post(client, f"/agents/{agent_id}/cfop-ippt-write", mode="NOT_A_REAL_MODE", confirm="GRAVAR")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_record_cfop_ippt_write_audits_only_accepts_cfop_or_ippt_and_ignores_other_fields():
|
||||
from central.app.cfop_ippt_audit import record_cfop_ippt_write_audits
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as s:
|
||||
installation = s.scalars(select(Installation)).one()
|
||||
from central.app.models import Agent
|
||||
agent = s.scalars(select(Agent)).one()
|
||||
n = record_cfop_ippt_write_audits(s, tenant_id=installation.tenant_id, installation=installation, agent=agent, job_id=1, cfop_ippt_apply={
|
||||
"applied": [{"product_code": "A1", "field": "CFOP", "outcome": "APPLIED", "old_value": None, "new_value": "5102"}],
|
||||
"aborted": [{"product_code": "A2", "field": "IPPT", "outcome": "ABORTED", "reason": "STALE_VALUE_CHANGED_SINCE_DECISION"},
|
||||
{"product_code": "A3", "field": "CCLASSTRIB", "outcome": "APPLIED", "new_value": "999999"}], # not CFOP/IPPT: must be ignored
|
||||
})
|
||||
assert n == 2
|
||||
rows = {r.product_code: r for r in s.scalars(select(CfopIpptWriteAudit))}
|
||||
assert set(rows) == {"A1", "A2"}
|
||||
assert rows["A1"].outcome == "APPLIED" and rows["A2"].reason == "STALE_VALUE_CHANGED_SINCE_DECISION"
|
||||
@@ -0,0 +1,62 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app import api as api_module
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Base
|
||||
|
||||
HEADERS = {"Authorization": "Bearer test-token"}
|
||||
PAYLOAD = {"current_ncm": "22030000", "description": "CERVEJA LATA"}
|
||||
|
||||
|
||||
def make_client(monkeypatch, calls, engine_version="0.3.0", datasets=None):
|
||||
versions = datasets if datasets is not None else {"ncm": "aaa"}
|
||||
|
||||
class FakeClassifier:
|
||||
def __init__(self, repository): pass
|
||||
|
||||
def classify(self, product):
|
||||
calls.append(product)
|
||||
return {"status": "EXACT_NCM", "engine_version": api_module.ENGINE_VERSION, "decision_version": "x", "dataset_versions": dict(versions), "classification": {"ncm": product["current_ncm"]}}
|
||||
|
||||
monkeypatch.setattr(api_module, "Classifier", FakeClassifier)
|
||||
monkeypatch.setattr(api_module, "ENGINE_VERSION", engine_version)
|
||||
monkeypatch.setattr(api_module.DatasetService, "dataset_versions", lambda self: dict(versions))
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return TestClient(create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token")), versions
|
||||
|
||||
|
||||
def test_cache_hit_does_not_run_the_classifier(monkeypatch):
|
||||
calls = []
|
||||
client, _ = make_client(monkeypatch, calls)
|
||||
first = client.post("/v1/classify", json=PAYLOAD, headers=HEADERS).json()
|
||||
assert first["provenance"]["cache_hit"] is False and len(calls) == 1
|
||||
for _ in range(3):
|
||||
again = client.post("/v1/classify", json=PAYLOAD, headers=HEADERS).json()
|
||||
assert again["provenance"]["cache_hit"] is True
|
||||
assert {k: v for k, v in again.items() if k != "provenance"} == {k: v for k, v in first.items() if k != "provenance"} # same fiscal result
|
||||
assert len(calls) == 1 # CLASSIFIER_EXECUTIONS_ON_HIT = 0
|
||||
|
||||
|
||||
def test_batch_hits_skip_the_classifier_and_new_inputs_miss(monkeypatch):
|
||||
calls = []
|
||||
client, _ = make_client(monkeypatch, calls)
|
||||
products = [PAYLOAD, {"current_ncm": "19022000", "description": "MASSA"}]
|
||||
client.post("/v1/classify/batch", json={"products": products}, headers=HEADERS)
|
||||
assert len(calls) == 2
|
||||
res = client.post("/v1/classify/batch", json={"products": products + [{"current_ncm": "20099000", "description": "SUCO"}]}, headers=HEADERS).json()["results"]
|
||||
assert [r["provenance"]["cache_hit"] for r in res] == [True, True, False] and len(calls) == 3
|
||||
|
||||
|
||||
def test_engine_or_dataset_version_change_invalidates_naturally(monkeypatch):
|
||||
calls = []
|
||||
client, versions = make_client(monkeypatch, calls)
|
||||
client.post("/v1/classify", json=PAYLOAD, headers=HEADERS)
|
||||
versions["ncm"] = "bbb" # a new ACTIVE dataset version changes dataset_versions()
|
||||
assert client.post("/v1/classify", json=PAYLOAD, headers=HEADERS).json()["provenance"]["cache_hit"] is False
|
||||
monkeypatch.setattr(api_module, "ENGINE_VERSION", "0.4.0")
|
||||
assert client.post("/v1/classify", json=PAYLOAD, headers=HEADERS).json()["provenance"]["cache_hit"] is False
|
||||
assert len(calls) == 3
|
||||
@@ -0,0 +1,31 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from central.app.classification import classify_tipi_rate
|
||||
|
||||
|
||||
def _entry(ex=None, ipi_rate=None, ipi_rate_text=None):
|
||||
return SimpleNamespace(ex=ex, ipi_rate=ipi_rate, ipi_rate_text=ipi_rate_text)
|
||||
|
||||
|
||||
def test_ex_specific():
|
||||
assert classify_tipi_rate(_entry(ex="01")) == "EX_SPECIFIC"
|
||||
|
||||
|
||||
def test_source_blank():
|
||||
assert classify_tipi_rate(_entry(ipi_rate_text=None)) == "SOURCE_BLANK"
|
||||
|
||||
|
||||
def test_nt_or_non_taxed():
|
||||
assert classify_tipi_rate(_entry(ipi_rate_text="NT")) == "NT_OR_NON_TAXED"
|
||||
|
||||
|
||||
def test_explicit_zero_text():
|
||||
assert classify_tipi_rate(_entry(ipi_rate_text="0,00")) == "EXPLICIT_ZERO"
|
||||
|
||||
|
||||
def test_explicit_zero_numeric():
|
||||
assert classify_tipi_rate(_entry(ipi_rate=0, ipi_rate_text="0")) == "EXPLICIT_ZERO"
|
||||
|
||||
|
||||
def test_other_unrecognized_marker():
|
||||
assert classify_tipi_rate(_entry(ipi_rate_text="ISENTO")) == "OTHER"
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Client mirror from GTI Gerenciador (central/app/client_sync.py, web.py's
|
||||
/agents/new). GTI Gerenciador is the single source of truth: Tenant rows
|
||||
with source='GTI_GERENCIADOR' are upserted from it; source='LOCAL' rows
|
||||
(pre-existing dev/test tenants) are never touched or offered."""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import client_sync, config
|
||||
from central.app.models import Tenant
|
||||
from central.tests.test_control_plane import setup_db
|
||||
|
||||
CLIENTS = [
|
||||
{"external_client_id": 1, "code": "000001", "display_name": "Mercado Ativo", "active": True},
|
||||
{"external_client_id": 2, "code": "000002", "display_name": "Loja Inativa", "active": False},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def factory():
|
||||
return setup_db()
|
||||
|
||||
|
||||
def test_sync_creates_tenants_from_gerenciador(factory):
|
||||
with factory() as session:
|
||||
result = client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
assert result.created == 2 and result.updated == 0 and result.fetched == 2
|
||||
assert result.active == 1 and result.inactive == 1
|
||||
rows = {row.external_client_id: row for row in session.scalars(select(Tenant)).all()}
|
||||
assert rows[1].name == "Mercado Ativo" and rows[1].source == "GTI_GERENCIADOR" and rows[1].active is True
|
||||
assert rows[2].active is False and rows[2].external_code == "000002"
|
||||
|
||||
|
||||
def test_sync_is_idempotent_and_updates_changed_fields(factory):
|
||||
with factory() as session:
|
||||
client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
result = client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
assert result.created == 0 and result.unchanged == 2
|
||||
|
||||
renamed = [{**CLIENTS[0], "display_name": "Mercado Ativo Renomeado"}, CLIENTS[1]]
|
||||
result2 = client_sync.sync_from_gerenciador(session, renamed)
|
||||
session.commit()
|
||||
assert result2.updated == 1 and result2.unchanged == 1
|
||||
row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
|
||||
assert row.name == "Mercado Ativo Renomeado"
|
||||
|
||||
|
||||
def test_sync_never_touches_local_tenants(factory):
|
||||
with factory() as session:
|
||||
session.add(Tenant(name="Acme", source="LOCAL"))
|
||||
session.commit()
|
||||
client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
local = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
assert local.source == "LOCAL" and local.external_client_id is None
|
||||
|
||||
|
||||
def test_sync_disambiguates_a_name_collision_with_an_existing_tenant(factory):
|
||||
with factory() as session:
|
||||
session.add(Tenant(name="Mercado Ativo", source="LOCAL"))
|
||||
session.commit()
|
||||
client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
new_row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
|
||||
assert new_row.name == "Mercado Ativo (000001)" # the LOCAL tenant keeps the plain name
|
||||
|
||||
|
||||
def test_deactivating_a_client_in_gerenciador_is_reflected_without_deleting_history(factory):
|
||||
with factory() as session:
|
||||
client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
now_inactive = [{**CLIENTS[0], "active": False}, CLIENTS[1]]
|
||||
client_sync.sync_from_gerenciador(session, now_inactive)
|
||||
session.commit()
|
||||
row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
|
||||
assert row.active is False # row (and every FK pointing at it) is kept — never deleted
|
||||
|
||||
|
||||
def test_disabled_without_configured_url_or_token(monkeypatch):
|
||||
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, gerenciador_internal_url="", fiscal_internal_api_token=""))
|
||||
assert client_sync.enabled() is False
|
||||
with pytest.raises(client_sync.ClientSyncUnavailable):
|
||||
client_sync.fetch_from_gerenciador()
|
||||
@@ -0,0 +1,13 @@
|
||||
def test_company_session_is_company_first_review_only_and_has_ipi_impact(tmp_path):
|
||||
from scripts.run_company_decision_session import build_session
|
||||
|
||||
session = build_session(tmp_path, "docs/LOCAL_FISCAL_CONTEXT_QUESTIONS_V2.json") if (tmp_path / "docs/LOCAL_FISCAL_CONTEXT_QUESTIONS_V2.json").exists() else None
|
||||
# Use a small local fixture because the production artifact is intentionally large.
|
||||
if session is None:
|
||||
(tmp_path / "docs/runs/multi_db").mkdir(parents=True)
|
||||
(tmp_path / "docs").mkdir(exist_ok=True)
|
||||
(tmp_path / "docs/LOCAL_FISCAL_CONTEXT_QUESTIONS_V2.json").write_text('{"questions":[{"QUESTION_ID":"Q1","DATABASE":"A","SCOPE":"COMPANY","KEY":"entry_scope","VALID_OPTIONS":["INTRA","INTER","MIXED_OR_DYNAMIC"],"QUESTION":"Escopo?"}]}', encoding="utf-8")
|
||||
(tmp_path / "docs/FISCAL_COVERAGE_FULL_V3.json").write_text('{"total_products":1,"ipi":{"numeric":1,"non_numeric":0,"ambiguous":0}}', encoding="utf-8")
|
||||
session = build_session(tmp_path)
|
||||
assert all(question["scope"] == "COMPANY" for question in session["questions"])
|
||||
assert session["persisted"] is False and session["firebird_writes"] == 0
|
||||
@@ -0,0 +1,11 @@
|
||||
import importlib
|
||||
|
||||
|
||||
def test_database_url_encodes_password_from_component_environment(monkeypatch):
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
monkeypatch.setenv("POSTGRES_PASSWORD", "a@b/c")
|
||||
import central.app.config as config
|
||||
|
||||
module = importlib.reload(config)
|
||||
|
||||
assert module.settings.database_url == "postgresql+psycopg://gti:a%40b%2Fc@postgres:5432/gti_fiscal"
|
||||
@@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
|
||||
from central.scripts.generate_control_panel_fixture import generate_fixture
|
||||
from central.app.models import AgentEvent, AgentSnapshot, BlockedProduct, Installation, Job
|
||||
from central.tests.test_control_plane import setup_db
|
||||
|
||||
|
||||
def test_fixture_creates_one_hundred_varied_installations_and_events():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
result = generate_fixture(session, agent_count=100, seed=11)
|
||||
assert result == {"agents": 100, "events": 500, "blockers": 51, "jobs": 100}
|
||||
assert session.query(Installation).count() == 100
|
||||
assert session.query(AgentEvent).count() == 500
|
||||
assert session.query(Job).count() == 100
|
||||
snapshots = session.query(AgentSnapshot).all()
|
||||
assert {row.agent_health for row in snapshots} == {"ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN"}
|
||||
assert {row.version_drift for row in snapshots} == {"CURRENT", "OUTDATED", "UNKNOWN"}
|
||||
assert {row.installation_id for row in session.query(Installation).all()} == {str(uuid.uuid5(uuid.NAMESPACE_DNS, f"fixture-{index}")) for index in range(100)}
|
||||
assert all(row.fiscally_complete + row.safe_blocked + row.failed_products == row.total_products for row in snapshots)
|
||||
assert all(row.coverage_percent == round(row.fiscally_complete * 100 / row.total_products, 1) for row in snapshots)
|
||||
|
||||
blockers = session.query(BlockedProduct).all()
|
||||
assert len({row.blocker_fingerprint for row in blockers}) == len(blockers)
|
||||
for snapshot in snapshots:
|
||||
product_codes = {row.product_code for row in blockers if row.installation_id == snapshot.installation_id and row.resolved_at is None}
|
||||
assert snapshot.safe_blocked == len(product_codes)
|
||||
repeated_product = next(row for row in blockers if row.product_code == "P000000")
|
||||
same_product_blockers = [row for row in blockers if row.tenant_id == repeated_product.tenant_id and row.installation_id == repeated_product.installation_id and row.product_code == repeated_product.product_code]
|
||||
assert len(same_product_blockers) >= 2
|
||||
assert all(row.resolved_at is None for row in same_product_blockers)
|
||||
assert all(row.tenant_id == session.query(Installation).filter_by(id=row.installation_id).one().tenant_id for row in blockers)
|
||||
|
||||
failed_jobs = session.query(Job).filter_by(status="FAILED").all()
|
||||
assert failed_jobs and all(job.started_at is not None and job.finished_at is not None and job.finished_at >= job.started_at for job in failed_jobs)
|
||||
linked_events = session.query(AgentEvent).filter(AgentEvent.job_id.is_not(None), AgentEvent.blocker_id.is_not(None)).all()
|
||||
assert linked_events
|
||||
for event in linked_events:
|
||||
job = session.get(Job, event.job_id)
|
||||
blocker = session.get(BlockedProduct, event.blocker_id)
|
||||
installation = session.query(Installation).filter_by(id=blocker.installation_id).one()
|
||||
assert event.installation_id == installation.id == session.query(Installation).filter_by(agent_id=job.agent_id).one().id
|
||||
assert event.tenant_id == blocker.tenant_id == installation.tenant_id
|
||||
assert event.product_code == blocker.product_code
|
||||
assert all("password" not in event.message.lower() and "token" not in event.message.lower() for event in session.query(AgentEvent).all())
|
||||
@@ -0,0 +1,785 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.blocked_products import upsert_blocker
|
||||
from central.app.events import record_event
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentEvent, AgentSnapshot, Installation, Job, Tenant
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
|
||||
def _panel_client():
|
||||
session_factory = setup_db()
|
||||
agent_id, _ = seed(session_factory)
|
||||
now = datetime(2026, 9, 22, 19, 11, tzinfo=timezone.utc)
|
||||
with session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
agent = session.get(Agent, agent_id)
|
||||
agent.agent_version = "0.9.0"
|
||||
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
|
||||
snapshot.total_products = 50
|
||||
snapshot.fiscally_complete = 49
|
||||
snapshot.safe_blocked = 1
|
||||
snapshot.failed_products = 0
|
||||
snapshot.coverage_percent = 98
|
||||
job = Job(
|
||||
agent_id=agent_id,
|
||||
kind="REPROCESS_PRODUCTS",
|
||||
status="RUNNING",
|
||||
created_at=now - timedelta(minutes=3),
|
||||
started_at=now - timedelta(minutes=2),
|
||||
payload={"codigos": ["P-001"]},
|
||||
)
|
||||
session.add(job)
|
||||
session.flush()
|
||||
blocker = upsert_blocker(
|
||||
session,
|
||||
installation.tenant_id,
|
||||
{
|
||||
"company": "Acme",
|
||||
"product_code": "P-001",
|
||||
"description": "Produto de teste",
|
||||
"blocker_type": "BLOCKED_MISSING_EVIDENCE",
|
||||
"field": "CFOP",
|
||||
"reason_code": "MISSING_CONTEXT",
|
||||
"input_fingerprint": "page-test",
|
||||
"evidence_summary": {"source": "snapshot"},
|
||||
},
|
||||
installation_id=installation.id,
|
||||
)
|
||||
blocker.first_seen_at = now - timedelta(days=1, seconds=11)
|
||||
blocker.last_seen_at = now.replace(second=37)
|
||||
record_event(
|
||||
session,
|
||||
tenant_id=installation.tenant_id,
|
||||
installation_id=installation.id,
|
||||
agent_id=agent_id,
|
||||
severity="WARNING",
|
||||
event_type="PRODUCT_BLOCKED",
|
||||
message="Contexto fiscal ausente",
|
||||
job_id=job.id,
|
||||
product_code="P-001",
|
||||
blocker_id=blocker.id,
|
||||
occurred_at=now,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
|
||||
client._session_factory = session_factory
|
||||
login = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert login.status_code == 303
|
||||
return client, agent_id, blocker.id
|
||||
|
||||
|
||||
def test_dashboard_renders_operational_queue_labels_and_human_dates():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Servidores", "Online", "Atenção", "Offline", "Falhou", "Não informado"):
|
||||
assert label in response.text
|
||||
assert "Produtos com blockers abertos" in response.text
|
||||
assert "Blockers abertos" in response.text
|
||||
assert "Atenção necessária" in response.text
|
||||
assert "Ver blockers" in response.text
|
||||
assert "Jobs com falha" in response.text
|
||||
assert "Agents desatualizados" in response.text
|
||||
assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", response.text)
|
||||
|
||||
|
||||
def test_dashboard_coverage_displays_reconciled_product_buckets():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Produtos avaliados", "Completos", "Bloqueados com segurança", "Falhas técnicas", "Não classificados"):
|
||||
assert label in response.text
|
||||
assert "Produtos com blockers abertos" in response.text
|
||||
assert "Cobertura reconciliada" in response.text
|
||||
|
||||
|
||||
def test_agents_scope_querystring_distinguishes_active_inventory_from_history():
|
||||
client, _, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
tenant.active = False
|
||||
session.commit()
|
||||
|
||||
current = client.get("/agents?scope=active")
|
||||
history = client.get("/agents?scope=all")
|
||||
|
||||
assert current.status_code == history.status_code == 200
|
||||
assert "Acme ERP" not in current.text
|
||||
assert "Acme ERP" in history.text
|
||||
|
||||
|
||||
def test_blockers_and_jobs_scope_routes_keep_history_and_server_filters():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
agent = session.get(Agent, agent_id)
|
||||
agent.enabled = False
|
||||
session.commit()
|
||||
|
||||
assert "P-001" not in client.get("/blockers?scope=active&field=CFOP&reason_code=MISSING_CONTEXT").text
|
||||
assert "P-001" in client.get("/blockers?scope=all&field=CFOP&reason_code=MISSING_CONTEXT").text
|
||||
assert "Acme ERP" not in client.get("/jobs?scope=active&status=RUNNING").text
|
||||
assert "Acme ERP" in client.get("/jobs?scope=all&status=RUNNING").text
|
||||
|
||||
|
||||
def test_dashboard_links_reach_scoped_health_blocker_failed_job_and_outdated_filters():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
upsert_blocker(session, installation.tenant_id, {"company": "Acme", "product_code": "P-NCM", "description": "Mouse GTI", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "NCM", "reason_code": "INVALID_NCM", "input_fingerprint": "ncm-link"}, installation_id=installation.id)
|
||||
session.add(Job(agent_id=agent_id, kind="SYNC_INCREMENTAL", status="FAILED", created_at=datetime.now(timezone.utc), error="falha longa"))
|
||||
session.commit()
|
||||
|
||||
home = client.get("/").text
|
||||
|
||||
assert 'href="/agents?scope=active"' in home
|
||||
assert 'href="/agents?scope=active&health=ONLINE"' in home
|
||||
assert 'href="/blockers?scope=active&status=OPEN&field=NCM"' in home
|
||||
assert 'href="/jobs?scope=active&status=FAILED"' in home
|
||||
assert 'href="/agents?scope=active&version=outdated"' in home
|
||||
|
||||
|
||||
def test_list_filters_are_visible_and_clearable_with_ptbr_statuses():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
agents = client.get("/agents?scope=active&health=ONLINE").text
|
||||
blockers = client.get("/blockers?scope=active&field=CFOP").text
|
||||
jobs = client.get("/jobs?scope=active&status=RUNNING").text
|
||||
|
||||
assert 'name="health"' in agents and 'value="ONLINE" selected' in agents
|
||||
assert 'href="/agents?scope=active"' in agents and "Limpar filtros" in agents
|
||||
assert "Saúde técnica: Online" not in agents
|
||||
assert 'name="field"' in blockers and 'value="CFOP" selected' in blockers
|
||||
assert "Evidência fiscal ausente" in blockers and "Prioridade: HIGH" not in blockers
|
||||
assert 'href="/blockers?scope=active"' in blockers and "Limpar filtros" in blockers
|
||||
assert 'value="RUNNING" selected' in jobs and "Executando" in jobs
|
||||
assert 'href="/jobs?scope=active"' in jobs and "Limpar filtros" in jobs
|
||||
|
||||
|
||||
def test_login_has_centered_card_and_explicit_field_labels():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
response = client.get("/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'class="login-page"' in response.text
|
||||
# Same card component as the GTI Gerenciador login (gti-theme.css .cartao).
|
||||
assert 'class="cartao login-card"' in response.text
|
||||
assert '<label for="login-username">Usuário</label>' in response.text
|
||||
assert '<label for="login-password">Senha</label>' in response.text
|
||||
|
||||
|
||||
def test_dashboard_lists_bounded_failed_job_rows_with_identity_error_age_and_empty_state():
|
||||
client, agent_id, _ = _panel_client()
|
||||
factory = client._session_factory
|
||||
with factory() as session:
|
||||
for index in range(12):
|
||||
session.add(Job(agent_id=agent_id, kind="REPROCESS_PRODUCTS", status="FAILED", error=f"erro curto {index}", created_at=datetime(2026, 9, 22, 18, 0, index, tzinfo=timezone.utc), finished_at=datetime(2026, 9, 22, 18, 5, index, tzinfo=timezone.utc)))
|
||||
session.commit()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "Acme" in response.text and "Acme ERP" in response.text and "srv-01" in response.text
|
||||
assert "erro curto" in response.text and "Falhas recentes" in response.text and "Falhou" in response.text
|
||||
assert 'href="/jobs?scope=active&status=FAILED&job_id=' in response.text
|
||||
assert response.text.count('class="error-text job-error-summary"') == 10 # bounded, one clamped summary per row
|
||||
assert 'title="2026-09-22T18:05:11+00:00"' in response.text
|
||||
assert ">22/09/2026 15:05<" in response.text
|
||||
with factory() as session:
|
||||
session.query(Job).filter(Job.status == "FAILED").delete()
|
||||
session.commit()
|
||||
assert "Nenhum job com falha." in client.get("/").text
|
||||
|
||||
|
||||
def test_blocker_summary_uses_complete_filtered_relation_and_tenant_product_identity():
|
||||
client, agent_id, _ = _panel_client()
|
||||
factory = client._session_factory
|
||||
with factory() as session:
|
||||
original = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
other_tenant = Tenant(name="Outra empresa")
|
||||
session.add(other_tenant); session.flush()
|
||||
other_agent = Agent(tenant_id=other_tenant.id, name="agent-2", token_hash="y" * 64)
|
||||
session.add(other_agent); session.flush()
|
||||
other_installation = Installation(agent_id=other_agent.id, tenant_id=other_tenant.id, installation_id="22222222-2222-4222-8222-222222222222", display_name="Outra ERP", hostname="srv-02")
|
||||
session.add(other_installation); session.flush()
|
||||
for index in range(55):
|
||||
upsert_blocker(session, original.tenant_id, {"company": "Acme", "product_code": f"P-{index:03}", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": f"many-{index}"}, installation_id=original.id)
|
||||
upsert_blocker(session, other_tenant.id, {"company": "Outra", "product_code": "P-000", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "NCM", "reason_code": "MISSING", "input_fingerprint": "other-tenant"}, installation_id=other_installation.id)
|
||||
resolved = upsert_blocker(session, original.tenant_id, {"company": "Acme", "product_code": "P-RES", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "ST", "reason_code": "MISSING", "input_fingerprint": "resolved"}, installation_id=original.id)
|
||||
resolved.resolved_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
open_page = client.get("/blockers?status=OPEN&field=CFOP")
|
||||
assert open_page.status_code == 200
|
||||
assert '<span>Blockers abertos</span><strong>56</strong>' in open_page.text
|
||||
assert '<span>Produtos afetados</span><strong>55</strong>' in open_page.text
|
||||
assert '<span>Clientes afetados</span><strong>1</strong>' in open_page.text
|
||||
resolved_page = client.get("/blockers?status=RESOLVED")
|
||||
assert '<span>Blockers abertos</span><strong>0</strong>' in resolved_page.text
|
||||
assert '<span>Produtos afetados</span><strong>1</strong>' in resolved_page.text
|
||||
all_page = client.get("/blockers?status=ALL")
|
||||
assert '<span>Blockers abertos</span><strong>57</strong>' in all_page.text
|
||||
assert '<span>Produtos afetados</span><strong>57</strong>' in all_page.text
|
||||
assert "Produtos afetados" in open_page.text and "Clientes afetados" in open_page.text
|
||||
|
||||
|
||||
def test_agents_render_separate_health_and_fiscal_status_with_server_sort():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
response = client.get("/agents?sort=worst_coverage&health=ONLINE&search=Acme")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Cliente / servidor", "Saúde", "Status fiscal", "Cobertura", "Produtos", "Versão", "Último contato"):
|
||||
assert label in response.text
|
||||
assert "Acme ERP" in response.text
|
||||
assert "srv-01" in response.text
|
||||
assert "98,0%" in response.text
|
||||
assert 'name="health"' in response.text
|
||||
assert 'value="Acme"' in response.text
|
||||
assert 'value="worst_coverage"' in response.text
|
||||
|
||||
|
||||
def test_agent_list_and_detail_show_nao_informado_when_snapshot_missing():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
session.query(AgentSnapshot).filter(AgentSnapshot.installation_id == installation.id).delete()
|
||||
session.commit()
|
||||
|
||||
listing = client.get("/agents")
|
||||
assert listing.status_code == 200
|
||||
assert "None completos" not in listing.text
|
||||
assert "None bloqueados" not in listing.text
|
||||
assert ">None<" not in listing.text
|
||||
assert "Não informado" in listing.text
|
||||
|
||||
detail = client.get(f"/agents/{agent_id}")
|
||||
assert detail.status_code == 200
|
||||
assert ">None<" not in detail.text
|
||||
assert "None completos" not in detail.text
|
||||
assert "Não informado" in detail.text
|
||||
|
||||
|
||||
def test_agent_list_hides_technical_uuid_and_explains_remainder():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
technical_uuid = installation.installation_id
|
||||
agent = session.get(Agent, agent_id)
|
||||
agent.agent_version = None
|
||||
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
|
||||
snapshot.version_drift = "UNKNOWN"
|
||||
session.commit()
|
||||
|
||||
response = client.get("/agents")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert technical_uuid not in response.text
|
||||
assert "bloqueados" in response.text
|
||||
assert "Não informada" in response.text
|
||||
assert "<td>Desconhecida<small>Desconhecida</small></td>" not in response.text
|
||||
|
||||
|
||||
def test_agent_list_and_detail_show_localized_last_seen_with_iso_tooltip():
|
||||
client, agent_id, _ = _panel_client()
|
||||
expected_iso = '2026-09-22T19:11:37+00:00'
|
||||
expected_display = '22/09/2026 16:11'
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
installation.last_seen_at = datetime(2026, 9, 22, 19, 11, 37, tzinfo=timezone.utc)
|
||||
session.commit()
|
||||
|
||||
agents = client.get("/agents")
|
||||
detail = client.get(f"/agents/{agent_id}")
|
||||
|
||||
assert agents.status_code == 200
|
||||
assert f'title="{expected_iso}"' in agents.text
|
||||
assert f'>{expected_display}<' in agents.text
|
||||
assert detail.status_code == 200
|
||||
assert f'title="{expected_iso}"' in detail.text
|
||||
assert f'>{expected_display} ·' in detail.text
|
||||
|
||||
|
||||
def test_jobs_render_client_identity_duration_and_preserve_filters():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
response = client.get("/jobs?status=RUNNING&search=Acme")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Cliente / servidor", "Tipo / job", "Status", "Criado", "Finalizado / duração", "Erro / ação"):
|
||||
assert label in response.text
|
||||
assert "Acme" in response.text
|
||||
assert "srv-01" in response.text
|
||||
assert "em andamento" in response.text
|
||||
assert 'value="Acme"' in response.text
|
||||
assert '<option value="RUNNING" selected>' in response.text
|
||||
assert "Agent 1" not in response.text
|
||||
|
||||
|
||||
def test_jobs_show_localized_created_and_finished_with_iso_tooltips():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
session.add(Job(agent_id=agent_id, kind="REPROCESS_PRODUCTS", status="COMPLETED", created_at=datetime(2026, 9, 22, 19, 8, tzinfo=timezone.utc), started_at=datetime(2026, 9, 22, 19, 9, tzinfo=timezone.utc), finished_at=datetime(2026, 9, 22, 19, 10, tzinfo=timezone.utc)))
|
||||
session.commit()
|
||||
|
||||
response = client.get("/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'title="2026-09-22T19:08:00+00:00"' in response.text
|
||||
assert 'title="2026-09-22T19:10:00+00:00"' in response.text
|
||||
assert ">22/09/2026 16:08<" in response.text
|
||||
assert ">22/09/2026 16:10<" in response.text
|
||||
|
||||
|
||||
def test_blocker_list_and_detail_show_localized_seen_times_with_iso_tooltips():
|
||||
client, _, blocker_id = _panel_client()
|
||||
|
||||
listing = client.get("/blockers")
|
||||
detail = client.get(f"/blockers/{blocker_id}")
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert 'title="2026-09-22T19:11:37+00:00"' in listing.text
|
||||
assert ">22/09/2026 16:11<" in listing.text
|
||||
assert detail.status_code == 200
|
||||
assert 'title="2026-09-21T19:10:49+00:00"' in detail.text
|
||||
assert 'title="2026-09-22T19:11:37+00:00"' in detail.text
|
||||
assert ">22/09/2026 16:11<" in detail.text
|
||||
|
||||
|
||||
def test_blockers_render_visible_filters_summary_priority_and_detail_link():
|
||||
client, _, blocker_id = _panel_client()
|
||||
|
||||
response = client.get("/blockers?client=Acme&installation=srv-01&field=CFOP&blocker_type=BLOCKED_MISSING_EVIDENCE&status=OPEN&age=0h&search=P-001")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Cliente", "Instalação / servidor", "Campo", "Tipo de blocker", "Status", "Idade", "Produto ou busca", "Blockers abertos", "Produtos afetados", "Clientes afetados", "CFOP", "ST", "ICMS", "CSOSN", "NCM", "Outros", "Prioridade"):
|
||||
assert label in response.text
|
||||
assert f"/blockers/{blocker_id}" in response.text
|
||||
assert "Alta" in response.text and "Evidência fiscal ausente" in response.text
|
||||
assert 'value="Acme"' in response.text
|
||||
assert 'value="BLOCKED_MISSING_EVIDENCE"' in response.text
|
||||
assert "window.confirm" not in response.text
|
||||
|
||||
|
||||
def test_agent_and_blocker_details_keep_safe_forms_and_human_event_rows():
|
||||
client, agent_id, blocker_id = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
upsert_blocker(session, installation.tenant_id, {"company": "Acme", "product_code": "LEGACY", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "ST", "reason_code": "LEGACY", "input_fingerprint": "tenant-only"})
|
||||
session.commit()
|
||||
|
||||
agent_response = client.get(f"/agents/{agent_id}?tab=events&severity=WARNING&event_type=PRODUCT_BLOCKED&page=2")
|
||||
blocker_response = client.get(f"/blockers/{blocker_id}")
|
||||
|
||||
assert agent_response.status_code == 200
|
||||
for label in ("Cliente", "Nome de exibição", "Hostname", "Saúde técnica", "Status fiscal", "Último heartbeat", "Versão", "Empresa", "UF", "CRT", "Eventos recentes", "Severidade", "Tipo de evento"):
|
||||
assert label in agent_response.text
|
||||
assert "Contexto fiscal ausente" in agent_response.text
|
||||
assert f"/blockers?agent_id={agent_id}" in agent_response.text
|
||||
assert ">CFOP</span><b>1</b>" in agent_response.text
|
||||
assert ">ST</span><b>0</b>" in agent_response.text
|
||||
agent_blockers = client.get(f"/blockers?agent_id={agent_id}")
|
||||
assert "Produto de teste" in agent_blockers.text and "LEGACY" not in agent_blockers.text
|
||||
assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", re.sub(r' title="[^"]*"', "", agent_response.text))
|
||||
assert 'value="WARNING" selected' in agent_response.text
|
||||
assert 'value="PRODUCT_BLOCKED"' in agent_response.text
|
||||
|
||||
assert blocker_response.status_code == 200
|
||||
for label in ("Detalhes do blocker", "Evidência", "Contexto atual", "Salvar contexto e reprocessar", "Chave de contexto", "Valor", "Escopo", "Nota"):
|
||||
assert label in blocker_response.text
|
||||
assert 'name="context"' not in blocker_response.text
|
||||
assert "data-confirm" not in blocker_response.text
|
||||
assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", re.sub(r' title="[^"]*"', "", blocker_response.text))
|
||||
|
||||
|
||||
def test_legacy_blocker_detail_explains_unavailable_resolution_without_form():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
blocker = upsert_blocker(
|
||||
session,
|
||||
installation.tenant_id,
|
||||
{"company": "Acme", "product_code": "LEGACY", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "ST", "reason_code": "LEGACY", "input_fingerprint": "unbound-detail"},
|
||||
)
|
||||
session.commit()
|
||||
blocker_id = blocker.id
|
||||
|
||||
response = client.get(f"/blockers/{blocker_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "blocker legado de empresa" in response.text.lower()
|
||||
assert "sem vínculo comprovado com um servidor" in response.text.lower()
|
||||
assert "não é possível oferecer o reprocessamento de contexto com segurança" in response.text.lower()
|
||||
assert "Salvar contexto e reprocessar" not in response.text
|
||||
assert f'action="/blockers/{blocker_id}/resolve-context"' not in response.text
|
||||
|
||||
|
||||
def test_agent_detail_resolves_agent_after_first_hundred_installations():
|
||||
client, _, _ = _panel_client()
|
||||
factory = client._session_factory
|
||||
with factory() as session:
|
||||
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
target_id = 0
|
||||
for index in range(105):
|
||||
agent = Agent(tenant_id=tenant.id, name=f"late-{index}", token_hash=f"{index:064d}")
|
||||
session.add(agent); session.flush()
|
||||
session.add(Installation(agent_id=agent.id, tenant_id=tenant.id, installation_id=f"{index + 10:08d}-1111-4111-8111-111111111111", display_name=f"Late ERP {index}", hostname=f"late-{index}"))
|
||||
target_id = agent.id
|
||||
session.commit()
|
||||
response = client.get(f"/agents/{target_id}")
|
||||
assert response.status_code == 200
|
||||
assert "Late ERP 104" in response.text
|
||||
|
||||
|
||||
def test_agent_detail_reconciles_coverage_and_formats_missing_technical_fields():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
agent = session.get(Agent, agent_id)
|
||||
agent.agent_version = None
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
|
||||
snapshot.version_drift = "UNKNOWN"
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/agents/{agent_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Produtos", "Completos", "Bloqueados com segurança", "Falhas técnicas", "Não classificados", "Cobertura fiscal"):
|
||||
assert label in response.text
|
||||
assert "Não informado" in response.text
|
||||
assert "Desconhecida" in response.text
|
||||
assert "Desconhecida · Desconhecida" not in response.text
|
||||
assert "ODS -" not in response.text
|
||||
context = response.text.split("<h2>Contexto da empresa</h2>", 1)[1].split("</article>", 1)[0]
|
||||
assert "Reprocessar tudo" not in context # the only reprocess action lives in the Processamento panel
|
||||
assert response.text.count("Reprocessar tudo") == 1
|
||||
assert not re.search(r"<(?:a|button)\b[^>]*>\s*</(?:a|button)>", context)
|
||||
|
||||
|
||||
def test_agent_detail_reports_negative_coverage_counts_as_data_inconsistency():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
|
||||
snapshot.failed_products = -1
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/agents/{agent_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "contagens negativas" in response.text
|
||||
|
||||
|
||||
def test_agent_event_filters_lookahead_and_safe_event_reference_links_and_confirmation():
|
||||
client, agent_id, _ = _panel_client()
|
||||
factory = client._session_factory
|
||||
with factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
job = session.scalar(select(Job).where(Job.agent_id == agent_id))
|
||||
for index in range(27):
|
||||
session.add(AgentEvent(tenant_id=installation.tenant_id, installation_id=installation.id, agent_id=agent_id, severity="INFO", event_type="HEARTBEAT", message=f"evento {index}", structured_context={}, job_id=job.id, product_code="P-001", occurred_at=datetime(2026, 9, 21 + index % 2, tzinfo=timezone.utc)))
|
||||
session.add(AgentEvent(tenant_id=installation.tenant_id, installation_id=installation.id, agent_id=agent_id, severity="INFO", event_type="HEARTBEAT", message="evento anterior ao filtro", structured_context={}, occurred_at=datetime(2026, 9, 20, tzinfo=timezone.utc)))
|
||||
session.commit()
|
||||
first = client.get(f"/agents/{agent_id}?tab=events&since=2026-09-21&until=2026-09-22")
|
||||
assert first.status_code == 200
|
||||
assert 'name="since"' in first.text and 'name="until"' in first.text
|
||||
assert "evento anterior ao filtro" not in first.text
|
||||
assert "Próxima" in first.text and "since=2026-09-21" in first.text and "until=2026-09-22" in first.text
|
||||
last = client.get(f"/agents/{agent_id}?tab=events&event_page=2&since=2026-09-21&until=2026-09-22")
|
||||
assert "Anterior" in last.text and "Próxima" not in last.text
|
||||
assert "/jobs?job_id=" in first.text and "/blockers?agent_id=" in first.text
|
||||
csrf = client.cookies.get("gti_csrf")
|
||||
blocked = client.post(f"/agents/{agent_id}/reprocess", data={"csrf_token": csrf})
|
||||
assert blocked.status_code == 400 and "confirmar" in blocked.text.lower() and 'name="confirm"' in blocked.text
|
||||
confirmation = client.get(f"/agents/{agent_id}/reprocess-confirmation")
|
||||
assert confirmation.status_code == 200 and 'name="confirm"' in confirmation.text and 'required' in confirmation.text
|
||||
assert f'href="/agents/{agent_id}"' in confirmation.text
|
||||
assert client.post(f"/agents/{agent_id}/reprocess", data={"csrf_token": "wrong", "confirm": "yes"}).status_code == 403
|
||||
assert client.post(f"/agents/{agent_id}/reprocess", data={"csrf_token": csrf, "confirm": "yes"}, follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_theme_defaults_to_dark_and_never_overrides_a_saved_choice():
|
||||
client, _, _ = _panel_client()
|
||||
base = client.get("/agents").text
|
||||
# no saved preference -> dark; an explicit "claro" is the only thing that yields light
|
||||
assert 'tema === "claro" ? "claro" : "escuro"' in base
|
||||
assert "prefers-color-scheme" not in (Path(__file__).parents[1] / "static" / "tema.js").read_text(encoding="utf-8")
|
||||
script = (Path(__file__).parents[1] / "static" / "tema.js").read_text(encoding="utf-8")
|
||||
assert 'return "escuro"' in script and 'localStorage.setItem("tema", novo)' in script
|
||||
for page in ("login.html", "logged_out.html", "sso_error.html"):
|
||||
assert 'data-tema="escuro"' in (Path(__file__).parents[1] / "templates" / page).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_operator_pages_use_portuguese_titles_and_consistent_ctas():
|
||||
client, agent_id, _ = _panel_client()
|
||||
assert "<title>Agents / Servidores | GTI Fiscal</title>" in client.get("/agents").text
|
||||
assert "<title>Blockers | GTI Fiscal</title>" in client.get("/blockers").text
|
||||
assert "<title>Jobs | GTI Fiscal</title>" in client.get("/jobs").text
|
||||
dashboard = client.get("/").text
|
||||
for old in ("Abrir fila", "Ver instalações", "Ver todos", "Ver job #"):
|
||||
assert old not in dashboard
|
||||
|
||||
|
||||
def test_agent_detail_processing_hierarchy_and_status_badges():
|
||||
client, agent_id, _ = _panel_client()
|
||||
page = client.get(f"/agents/{agent_id}").text
|
||||
assert page.count("Reprocessar tudo") == 1
|
||||
assert 'class="primary-action"' in page and 'class="botao secondary"' in page and 'class="caution"' in page
|
||||
assert "Mantém o heartbeat, mas não executa jobs" in page and "Recalcula todos os produtos" in page
|
||||
assert "Saúde técnica: " not in page and "Status fiscal: " not in page # context lives in the small label, not in the badge
|
||||
assert '<small class="status-label">Saúde técnica</small>' in page
|
||||
|
||||
|
||||
def _complete_sync(client, agent_id, fiscal_context):
|
||||
with client._session_factory() as session:
|
||||
session.add(Job(agent_id=agent_id, kind="SYNC_INCREMENTAL", status="COMPLETED", payload={}, result_summary={"fiscal_context": fiscal_context}, created_at=datetime(2026, 9, 22, 19, 0, tzinfo=timezone.utc),
|
||||
finished_at=datetime(2026, 9, 22, 19, 5, tzinfo=timezone.utc)))
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_agent_detail_has_one_coverage_and_shows_rtc_status_only_when_reported():
|
||||
client, agent_id, _ = _panel_client()
|
||||
plain = client.get(f"/agents/{agent_id}").text
|
||||
assert "Reforma Tributária" not in plain # older Agents do not report it
|
||||
for text in (plain,):
|
||||
assert "Escopo anterior" not in text and "Escopo ampliado" not in text and "aplicabilidade desconhecida" not in text
|
||||
_complete_sync(client, agent_id, {"rtc_enabled": False, "rtc_supported": True, "rtc_catalog_valid": True})
|
||||
off = client.get(f"/agents/{agent_id}").text
|
||||
assert re.search(r"Reforma Tributária</dt><dd><span[^>]*>Desativada</span>", off) and "Escopo anterior" not in off and "Escopo ampliado" not in off
|
||||
assert "cClassTrib" not in off # RTC off never surfaces RTC as a problem
|
||||
_complete_sync(client, agent_id, {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True})
|
||||
on = client.get(f"/agents/{agent_id}").text
|
||||
assert re.search(r"Reforma Tributária</dt><dd><span[^>]*>Ativa</span>", on)
|
||||
|
||||
|
||||
def test_pis_cofins_and_rtc_blockers_appear_in_the_same_queue_and_dashboard_breakdown():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
for code, field, reason in (("P-010", "PIS", "PIS_CST_INVALID"), ("P-011", "COFINS", "COFINS_CST_MISSING"), ("P-012", "RTC", "CCLASSTRIB_MISSING")):
|
||||
upsert_blocker(session, installation.tenant_id, {"company": "Acme", "product_code": code, "description": "x", "blocker_type": "BLOCKED_FISCAL_DATA", "field": field, "reason_code": reason, "input_fingerprint": code},
|
||||
installation_id=installation.id)
|
||||
dash = client.get("/").text
|
||||
for key in ("PIS", "COFINS", "RTC", "CEST"):
|
||||
assert f"<span>{key}</span>" in dash
|
||||
queue = client.get("/blockers?scope=active&status=OPEN").text
|
||||
assert "CST PIS inválido" in queue and "CST COFINS ausente" in queue and "cClassTrib ausente" in queue
|
||||
assert "fila RTC" not in queue.lower()
|
||||
filtered = client.get("/blockers?scope=active&status=OPEN&field=RTC").text
|
||||
assert "cClassTrib ausente" in filtered and "CST PIS inválido" not in filtered
|
||||
|
||||
|
||||
def test_rtc_status_shows_where_and_when_the_agent_observed_it():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_complete_sync(client, agent_id, {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True, "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", "rtc_observed_at": "2026-09-24T20:00:00+00:00"})
|
||||
text = client.get(f"/agents/{agent_id}").text
|
||||
assert 'title="Lido no banco do cliente (PARAMETROS.USAR_REFORMA_TRIBUTARIA) em ' in text and ">Ativa<" in text
|
||||
_complete_sync(client, agent_id, {"rtc_enabled": False, "rtc_param_conflict": True, "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA"})
|
||||
conflict = client.get(f"/agents/{agent_id}").text
|
||||
assert "linhas de PARAMETROS divergentes" in conflict and ">Desativada<" in conflict
|
||||
for legacy in ("Escopo anterior", "Escopo ampliado", "Legacy coverage", "Expanded coverage"):
|
||||
assert legacy not in conflict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- RTC auto-apply authorization (central, default OFF)
|
||||
def _installation(client, agent_id):
|
||||
from central.app.models import Installation as Inst
|
||||
with client._session_factory() as session:
|
||||
row = session.scalar(select(Inst).where(Inst.agent_id == agent_id))
|
||||
return row.rtc_write_mode
|
||||
|
||||
|
||||
def test_rtc_write_mode_is_disabled_by_default_and_needs_csrf_valid_mode_and_a_typed_confirmation_for_automatic():
|
||||
client, agent_id, _ = _panel_client()
|
||||
assert _installation(client, agent_id) == "DISABLED"
|
||||
csrf = client.cookies.get("gti_csrf")
|
||||
url = f"/agents/{agent_id}/rtc-mode"
|
||||
assert client.post(url, data={"csrf_token": "wrong", "mode": "DRY_RUN"}).status_code == 403
|
||||
assert client.post(url, data={"csrf_token": csrf, "mode": "ENABLED"}).status_code == 422
|
||||
assert client.post(url, data={"csrf_token": csrf, "mode": "ENABLED", "confirm": "sim"}).status_code == 422
|
||||
assert client.post(url, data={"csrf_token": csrf, "mode": "bogus"}).status_code == 422
|
||||
assert _installation(client, agent_id) == "DISABLED"
|
||||
for mode, extra in (("DRY_RUN", {}), ("APPROVAL_REQUIRED", {}), ("ENABLED", {"confirm": "preencher"}), ("DISABLED", {})):
|
||||
assert client.post(url, data={"csrf_token": csrf, "mode": mode, **extra}, follow_redirects=False).status_code == 303
|
||||
assert _installation(client, agent_id) == mode
|
||||
from central.app.models import AuditEvent
|
||||
with client._session_factory() as session:
|
||||
actions = [a.action for a in session.scalars(select(AuditEvent).order_by(AuditEvent.id)).all() if a.action.startswith("RTC_")]
|
||||
assert actions == ["RTC_WRITE_MODE"] * 4
|
||||
|
||||
|
||||
def _seed_rtc(client, agent_id, n=120):
|
||||
from central.app.models import Installation as Inst, RtcCandidate, RtcWriteAudit
|
||||
with client._session_factory() as session:
|
||||
inst = session.scalar(select(Inst).where(Inst.agent_id == agent_id))
|
||||
inst.rtc_write_mode = "APPROVAL_REQUIRED"
|
||||
for i in range(n):
|
||||
session.add(RtcCandidate(tenant_id=inst.tenant_id, installation_id=inst.id, product_code=f"C{i:04d}", description=f"PRODUTO {i}", ncm="10063021", proposed="200003", cst_ibscbs="200", rule_id="RTC-R3", basis="RTC-R3:ANNEX", status="PENDING"))
|
||||
audit = RtcWriteAudit(tenant_id=inst.tenant_id, installation_id=inst.id, agent_id=agent_id, product_code="A1", field="CCLASSTRIB", outcome="APPLIED", old_value=None, new_value="200003", rule_id="RTC-R3", action="APPLY")
|
||||
session.add(audit)
|
||||
session.commit()
|
||||
return audit.id
|
||||
|
||||
|
||||
def test_rtc_control_page_paginates_candidates_on_the_server_and_shows_modes_counts_and_audit():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_seed_rtc(client, agent_id)
|
||||
page1 = client.get(f"/agents/{agent_id}/rtc").text
|
||||
assert "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente." in page1
|
||||
for label in ("Desativada", "Dry-run", "Aprovação manual", "Automática"):
|
||||
assert label in page1
|
||||
assert "Pendentes (120)" in page1 and "C0000" in page1 and "C0049" in page1 and "C0050" not in page1 and "Página 1 de 3" in page1 and "Aplicar aprovados agora" in page1
|
||||
page3 = client.get(f"/agents/{agent_id}/rtc?page=3").text
|
||||
assert "C0119" in page3 and "C0099" not in page3 and "Página 3 de 3" in page3
|
||||
assert "Reverter" in page3
|
||||
|
||||
|
||||
def test_rtc_candidate_actions_approve_reject_apply_and_rollback_are_csrf_protected_and_scoped():
|
||||
from central.app.models import Job, RtcCandidate, RtcWriteAudit
|
||||
client, agent_id, _ = _panel_client()
|
||||
audit_id = _seed_rtc(client, agent_id, n=10)
|
||||
csrf = client.cookies.get("gti_csrf")
|
||||
url = f"/agents/{agent_id}/rtc-candidates"
|
||||
assert client.post(url, data={"csrf_token": "wrong", "action": "approve"}).status_code == 403
|
||||
with client._session_factory() as session:
|
||||
ids = [c.id for c in session.scalars(select(RtcCandidate).order_by(RtcCandidate.id)).all()]
|
||||
assert client.post(url, content=f"csrf_token={csrf}&action=approve&ids={ids[0]}&ids={ids[1]}", headers={"content-type": "application/x-www-form-urlencoded"}, follow_redirects=False).status_code == 303
|
||||
assert client.post(f"{url}?all=1", data={"csrf_token": csrf, "action": "approve"}, follow_redirects=False).status_code == 303
|
||||
assert client.post(url, content=f"csrf_token={csrf}&action=reject&ids={ids[2]}", headers={"content-type": "application/x-www-form-urlencoded"}, follow_redirects=False).status_code == 303
|
||||
with client._session_factory() as session:
|
||||
counts = {c.product_code: c.status for c in session.scalars(select(RtcCandidate)).all()}
|
||||
assert list(counts.values()).count("REJECTED") == 1 and list(counts.values()).count("APPROVED") == 9
|
||||
jobs_before = len(session.scalars(select(Job)).all())
|
||||
assert client.post(url, data={"csrf_token": csrf, "action": "apply_approved"}, follow_redirects=False).status_code == 303
|
||||
with client._session_factory() as session:
|
||||
job = session.scalars(select(Job).order_by(Job.id.desc())).first()
|
||||
assert len(session.scalars(select(Job)).all()) == jobs_before + 1 and job.kind == "REPROCESS_PRODUCTS" and job.payload["reason"] == "RTC_APPLY_APPROVED" and len(job.payload["codigos"]) == 9
|
||||
assert client.post(url, data={"csrf_token": csrf, "action": "nope"}).status_code == 422
|
||||
roll = f"/agents/{agent_id}/rtc-rollback"
|
||||
assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id}).status_code == 422 # typed confirmation required
|
||||
assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id, "confirm": "reverter"}, follow_redirects=False).status_code == 303
|
||||
assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id, "confirm": "REVERTER"}).status_code == 409 # already requested: no duplicate
|
||||
with client._session_factory() as session:
|
||||
assert session.get(RtcWriteAudit, audit_id).rollback_state == "REQUESTED"
|
||||
|
||||
|
||||
def test_apply_approved_is_refused_outside_the_approval_mode():
|
||||
client, agent_id, _ = _panel_client()
|
||||
csrf = client.cookies.get("gti_csrf")
|
||||
assert client.post(f"/agents/{agent_id}/rtc-candidates", data={"csrf_token": csrf, "action": "apply_approved"}).status_code == 409
|
||||
|
||||
|
||||
def test_panel_shows_rtc_products_summary_and_the_pending_message_only_when_rtc_is_on():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_complete_sync(client, agent_id, {"rtc_enabled": False, "rtc_supported": True, "rtc_resolution": None})
|
||||
off = client.get(f"/agents/{agent_id}").text
|
||||
assert "Produtos RTC" not in off and "cClassTrib pendente" not in off and "Preenchimento automático" not in off
|
||||
resolution = {"total": 521, "valid_existing": 0, "ready_to_apply": 280, "ambiguous": 215, "insufficient": 26, "catalog_compatibility": {"compatibility": "PARTIAL_LOCAL_OUTDATED"}}
|
||||
_complete_sync(client, agent_id, {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True, "rtc_resolution": resolution})
|
||||
on = client.get(f"/agents/{agent_id}").text
|
||||
assert "Reforma Tributária ativa — cClassTrib pendente em 521 produtos" in on and "prontos para preenchimento 280" in on and "ambíguos/bloqueados 241" in on
|
||||
assert "Preenchimento automático" in on and "Desativada" in on and f"/agents/{agent_id}/rtc" in on
|
||||
for legacy in ("Escopo anterior", "Escopo ampliado"):
|
||||
assert legacy not in on
|
||||
|
||||
_complete_sync(client, agent_id, {
|
||||
"rtc_enabled": True,
|
||||
"rtc_supported": True,
|
||||
"rtc_catalog_valid": True,
|
||||
"cclasstrib_applicability": {"state": "NOT_APPLICABLE", "basis": "SIMPLES_CCLASSTRIB_NOT_EFFECTIVE"},
|
||||
"rtc_resolution": {"total": 521, "valid_existing": 0, "ready_to_apply": 0, "insufficient": 521},
|
||||
})
|
||||
not_applicable = client.get(f"/agents/{agent_id}").text
|
||||
assert "cClassTrib: Não utilizado neste cliente" in not_applicable
|
||||
assert "cClassTrib pendente em 521 produtos" not in not_applicable
|
||||
rtc_page = client.get(f"/agents/{agent_id}/rtc").text
|
||||
assert "cClassTrib: Não utilizado neste cliente" in rtc_page
|
||||
assert "cClassTrib pendente em 521 produtos" not in rtc_page
|
||||
|
||||
|
||||
def test_rtc_control_page_shows_human_labels_and_a_catalog_column_never_raw_enums():
|
||||
from central.app.panel_presentation import abort_reason_label, audit_outcome_label, candidate_status_label, catalog_state_label, blocker_reason_label, blocker_type_label, rtc_rule_label
|
||||
assert candidate_status_label("APPROVED") == "Aprovado" and audit_outcome_label("ROLLED_BACK") == "Revertido"
|
||||
assert rtc_rule_label("RTC-R4") == "Sugestão sem autorização de preenchimento (RTC-R4)"
|
||||
assert blocker_reason_label("INSUFFICIENT_EVIDENCE") == "Evidência fiscal insuficiente"
|
||||
assert blocker_type_label("CCLASSTRIB_CONFLICT") == "cClassTrib existente diverge da classificação proposta"
|
||||
assert catalog_state_label("MISSING_LOCAL") == "Classe oficial ausente no catálogo local do SGV"
|
||||
assert abort_reason_label("VALUE_ALREADY_SET_NEVER_OVERWRITTEN") == "cClassTrib existente diverge da classificação proposta"
|
||||
client, agent_id, _ = _panel_client()
|
||||
page = client.get(f"/agents/{agent_id}/rtc").text
|
||||
for header in ("cClassTrib atual", "cClassTrib proposto", "Fundamento", "Catálogo", "Situação", "Descrição"):
|
||||
assert header in page
|
||||
for raw in ("PENDING</td>", "APPLIED</td>", "ABORTED"):
|
||||
assert raw not in page
|
||||
|
||||
|
||||
def test_rtc_no_r3_candidates_shows_simples_explanation_and_filtered_blocker_link():
|
||||
from central.app.models import BlockedProduct, RtcCandidate, RtcWriteAudit
|
||||
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
installation.rtc_write_mode = "ENABLED"
|
||||
session.add(RtcCandidate(tenant_id=installation.tenant_id, installation_id=installation.id, product_code="R4-SUGGESTION", description="Sugestão", ncm="10063021", proposed="000001", cst_ibscbs="000", rule_id="RTC-R4", basis="suggestion only", status="PENDING"))
|
||||
session.add_all([
|
||||
BlockedProduct(
|
||||
tenant_id=installation.tenant_id,
|
||||
installation_id=installation.id,
|
||||
company="Acme",
|
||||
product_code=f"RTC-{index:04d}",
|
||||
description=f"Produto RTC {index}",
|
||||
blocker_type="BLOCKED_FISCAL_DATA",
|
||||
field="RTC",
|
||||
reason_code="CCLASSTRIB_MISSING_UNRESOLVED",
|
||||
evidence_summary={"reason": "RTC-R1:SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT"},
|
||||
input_fingerprint=f"rtc-input-{index}",
|
||||
blocker_fingerprint=f"rtc-blocker-{index}",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
for index in range(521)
|
||||
])
|
||||
session.add(Job(agent_id=agent_id, kind="REPROCESS_ALL", status="COMPLETED", payload={}, result_summary={
|
||||
"fiscal_context": {
|
||||
"rtc_enabled": True,
|
||||
"rtc_supported": True,
|
||||
"rtc_catalog_valid": True,
|
||||
"crt": "1",
|
||||
"legal_effective_context": {"cclasstrib_orientation": "PENDING_OFFICIAL_NT"},
|
||||
"rtc_resolution": {
|
||||
"total": 521, "valid_existing": 0, "ready_to_apply": 0, "suggestion_only": 0,
|
||||
"ambiguous": 0, "insufficient": 521,
|
||||
"by_reason": {"SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": 521},
|
||||
},
|
||||
},
|
||||
"rtc_auto_apply": {"mode": "ENABLED", "ready_total": 0, "preview": [], "write_attempts": 0, "write_successes": 0},
|
||||
}, created_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc)))
|
||||
session.commit()
|
||||
|
||||
page = client.get(f"/agents/{agent_id}/rtc")
|
||||
|
||||
assert page.status_code == 200
|
||||
assert "Não há produtos elegíveis para preenchimento automático." in page.text
|
||||
assert "521 produtos precisam de atenção fiscal, mas não podem ser preenchidos automaticamente pelas regras atuais." in page.text
|
||||
assert "Esta empresa está no Simples Nacional." in page.text
|
||||
assert "SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT" not in page.text
|
||||
assert "R4-SUGGESTION" not in page.text
|
||||
assert 'href="/blockers?scope=all&agent_id=' + str(agent_id) + '&field=RTC&status=OPEN"' in page.text
|
||||
assert "Nenhum candidato neste filtro." in page.text
|
||||
|
||||
blockers = client.get(f"/blockers?scope=all&agent_id={agent_id}&field=RTC&status=OPEN")
|
||||
assert blockers.status_code == 200
|
||||
assert "521 blockers" in blockers.text
|
||||
with client._session_factory() as session:
|
||||
assert session.scalar(select(Installation.rtc_write_mode).where(Installation.agent_id == agent_id)) == "ENABLED"
|
||||
assert session.query(RtcCandidate).filter_by(installation_id=installation.id).count() == 1
|
||||
assert session.query(RtcCandidate).filter_by(installation_id=installation.id, rule_id="RTC-R4").count() == 1
|
||||
assert session.query(RtcWriteAudit).filter_by(installation_id=installation.id).count() == 0
|
||||
@@ -0,0 +1,114 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import importlib
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from central.app.human_auth import create_admin, create_session, hash_password, verify_password
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentEvent, AgentSnapshot, Base, Installation, Tenant
|
||||
from central.app.events import purge_expired_events, record_event, sanitize_event
|
||||
from central.app.installation_identity import compute_agent_health
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def setup_db():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def seed(session_factory):
|
||||
with session_factory() as session:
|
||||
tenant = Tenant(name="Acme"); session.add(tenant); session.flush()
|
||||
agent = Agent(tenant_id=tenant.id, name="agent", token_hash="x" * 64); session.add(agent); session.flush()
|
||||
installation = Installation(agent_id=agent.id, tenant_id=tenant.id, installation_id="11111111-1111-4111-8111-111111111111", display_name="Acme ERP", hostname="srv-01", last_seen_at=datetime.now(timezone.utc)); session.add(installation); session.flush()
|
||||
session.add(AgentSnapshot(installation_id=installation.id, total_products=10, fiscally_complete=8, safe_blocked=2, blockers={"CFOP": 2}))
|
||||
admin = create_admin(session, "operator", "a-secure-password")
|
||||
return agent.id, admin.id
|
||||
|
||||
|
||||
def test_password_and_event_redaction_and_retention():
|
||||
assert verify_password("a-secure-password", hash_password("a-secure-password"))
|
||||
message, context = sanitize_event("password=bad token=secret", {"password": "bad", "nested": {"api_key": "key"}})
|
||||
assert "bad" not in message and context["password"] == "[REDACTED]" and context["nested"]["api_key"] == "[REDACTED]"
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
agent_id, _ = seed(session_factory)
|
||||
installation = session.query(Installation).first()
|
||||
old = record_event(session, tenant_id=installation.tenant_id, installation_id=installation.id, agent_id=agent_id, severity="INFO", event_type="HEARTBEAT", message="old", occurred_at=datetime.now(timezone.utc) - timedelta(days=100))
|
||||
keep = record_event(session, tenant_id=installation.tenant_id, installation_id=installation.id, agent_id=agent_id, severity="CRITICAL", event_type="CONNECTION_ERROR", message="keep", occurred_at=datetime.now(timezone.utc) - timedelta(days=100), is_audit=True)
|
||||
session.commit()
|
||||
assert purge_expired_events(session, retention_days=90) == 1
|
||||
assert session.get(AgentEvent, old.id) is None and session.get(AgentEvent, keep.id) is not None
|
||||
|
||||
|
||||
def test_short_local_password_requires_explicit_setting(monkeypatch):
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
monkeypatch.delenv("ALLOW_WEAK_DEV_PASSWORD", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
create_admin(session, "admin", "admin")
|
||||
monkeypatch.setenv("ALLOW_WEAK_DEV_PASSWORD", "1")
|
||||
admin = create_admin(session, "admin", "admin")
|
||||
assert admin.username == "admin"
|
||||
assert verify_password("admin", admin.password_hash)
|
||||
|
||||
|
||||
def test_removed_weak_password_flag_is_fail_closed_after_import(monkeypatch):
|
||||
monkeypatch.setenv("ALLOW_WEAK_DEV_PASSWORD", "1")
|
||||
import central.app.config as config_module
|
||||
import central.app.human_auth as human_auth_module
|
||||
|
||||
importlib.reload(config_module)
|
||||
importlib.reload(human_auth_module)
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
assert human_auth_module.create_admin(session, "admin", "admin").username == "admin"
|
||||
monkeypatch.delenv("ALLOW_WEAK_DEV_PASSWORD", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
human_auth_module.create_admin(session, "admin2", "admin")
|
||||
|
||||
|
||||
def test_production_admin_keeps_strong_password_minimum(monkeypatch):
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
monkeypatch.setenv("APP_ENV", "production")
|
||||
monkeypatch.setenv("ALLOW_WEAK_DEV_PASSWORD", "1")
|
||||
with pytest.raises(ValueError):
|
||||
create_admin(session, "admin", "admin")
|
||||
|
||||
|
||||
def test_health_model_separates_safe_blocked_from_technical_failure():
|
||||
now = datetime.now(timezone.utc)
|
||||
recent = SimpleNamespace(last_seen_at=now, failed_products=0, last_job_status="COMPLETED")
|
||||
blocked = SimpleNamespace(last_seen_at=now, failed_products=0, last_job_status="COMPLETED")
|
||||
failed = SimpleNamespace(last_seen_at=now, failed_products=1, last_job_status="FAILED", agent_health="FAILED")
|
||||
stale = SimpleNamespace(last_seen_at=now - timedelta(hours=2), failed_products=0, last_job_status="COMPLETED")
|
||||
assert compute_agent_health(recent)[0] == "ONLINE"
|
||||
assert compute_agent_health(blocked, supplemental=blocked)[0] == "ONLINE"
|
||||
assert compute_agent_health(failed)[0] == "FAILED"
|
||||
assert compute_agent_health(stale)[0] == "OFFLINE"
|
||||
assert compute_agent_health(SimpleNamespace(last_seen_at=None))[0] == "UNKNOWN"
|
||||
|
||||
|
||||
def test_human_login_and_dashboard_are_separate_from_agent_bearer():
|
||||
session_factory = setup_db(); seed(session_factory)
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
|
||||
assert client.get("/", follow_redirects=False).status_code == 303
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert client.get("/v1/dashboard/summary").status_code == 200
|
||||
assert client.get("/v1/jobs/global").status_code == 200
|
||||
assert TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)).get("/v1/dashboard/summary", headers={"Authorization": "Bearer x"}).status_code == 401
|
||||
assert client.get("/v1/agents").json()["items"][0]["installation_id"]
|
||||
csrf = client.cookies.get("gti_csrf")
|
||||
action = client.post("/v1/agents/1/actions/reprocess", json={"confirm": True, "csrf_token": csrf, "kind": "REPROCESS_ALL"})
|
||||
assert action.status_code == 200 and action.json()["status"] == "PENDING"
|
||||
denied = client.post("/v1/agents/1/actions/reprocess", json={"confirm": True, "csrf_token": "wrong", "kind": "REPROCESS_ALL"})
|
||||
assert denied.status_code == 403
|
||||
assert client.post("/logout").status_code == 403
|
||||
assert client.post("/logout", data={"csrf_token": csrf}, follow_redirects=False).status_code == 303
|
||||
@@ -0,0 +1,16 @@
|
||||
from central.app.deterministic_resolution import resolve_entry_scope, resolve_product_role, resolve_st_status
|
||||
|
||||
|
||||
def test_product_role_requires_two_independent_strong_signals():
|
||||
assert resolve_product_role({"recipe": True, "production": True})["value"] == "OWN_PRODUCTION"
|
||||
assert resolve_product_role({"single_flag": True})["value"] is None
|
||||
|
||||
|
||||
def test_st_distinguishes_subject_from_withheld():
|
||||
assert resolve_st_status({"official_cest": True, "purchase_retained": True})["value"] == "ST_ALREADY_WITHHELD_IN_PURCHASE"
|
||||
assert resolve_st_status({"official_cest": True, "purchase_retained": None})["status"] == "PENDING_ST_WITHHELD"
|
||||
|
||||
|
||||
def test_entry_scope_is_dynamic_when_both_ufs_are_observed():
|
||||
assert resolve_entry_scope(["BA", "SP"], "BA")["value"] == "MIXED_OR_DYNAMIC"
|
||||
assert resolve_entry_scope(["BA"], "BA")["value"] == "INTRA"
|
||||
@@ -0,0 +1,180 @@
|
||||
import pytest
|
||||
|
||||
from central.app.dry_run_planner import (
|
||||
build_field_proposals,
|
||||
execute,
|
||||
mva_evidence,
|
||||
split_by_eligibility,
|
||||
)
|
||||
|
||||
|
||||
def _decision(**overrides):
|
||||
base = {
|
||||
"status": "EXACT_NCM",
|
||||
"cest_status": "NO_CEST_MAPPING_FOUND",
|
||||
"ipi_status": "NO_TIPI_MAPPING_FOUND",
|
||||
"ba_st_mva_status": "NO_MVA_MAPPING_FOUND",
|
||||
"classification": {
|
||||
"ncm": "07069000",
|
||||
"cest": [],
|
||||
"ipi": None,
|
||||
"ba_st_mva": None,
|
||||
"ba_st_mva_candidates": [],
|
||||
},
|
||||
"dataset_versions": {"ncm": "n1", "cest": "c1", "tipi_ipi": "t1", "ba_st_mva": "m1"},
|
||||
"engine_version": "0.1.0",
|
||||
"decision_version": "mvp",
|
||||
"request_fingerprint": "fp",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_ipi_resolved_numeric_current_unknown():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "CURRENT_UNKNOWN"
|
||||
assert ipi["write_eligible"] is False
|
||||
assert ipi["proposed_value"] == 3.25
|
||||
|
||||
|
||||
def test_ipi_resolved_numeric_same_value():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {"ipi": 3.25, "ex_tipi": None})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "SAME_VALUE"
|
||||
assert ipi["write_eligible"] is False
|
||||
|
||||
|
||||
def test_ipi_resolved_numeric_change_required():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {"ipi": None, "ex_tipi": None})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "CHANGE_REQUIRED"
|
||||
assert ipi["write_eligible"] is True
|
||||
assert ipi["proposed_value"] == 3.25
|
||||
|
||||
|
||||
def test_ipi_non_numeric_marker_never_proposes_a_write():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "NT", "rate_numeric": None, "rate_kind": "NON_NUMERIC", "rate_classification": "NT_OR_NON_TAXED", "ex": None}})
|
||||
proposals = build_field_proposals("000004", decision, {"ipi": None, "ex_tipi": None})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "NON_NUMERIC_IPI_STATUS"
|
||||
assert ipi["proposed_value"] is None
|
||||
assert ipi["write_eligible"] is False
|
||||
assert ipi["reason_code"] == "TIPI_RATE_NT_OR_NON_TAXED"
|
||||
|
||||
|
||||
def test_ipi_resolved_without_concrete_rate_still_produces_a_row():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": None, "rate_numeric": None, "rate_kind": "BLANK", "rate_classification": "SOURCE_BLANK", "ex": None}})
|
||||
proposals = build_field_proposals("000018", decision, {"ipi": 5, "ex_tipi": None})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "SOURCE_BLANK"
|
||||
assert ipi["proposed_value"] is None
|
||||
assert ipi["write_eligible"] is False
|
||||
assert ipi["reason_code"] == "TIPI_RATE_SOURCE_BLANK"
|
||||
|
||||
|
||||
def test_no_ex_tipi_row_when_no_ex_candidates():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}, "ipi_candidates": []})
|
||||
proposals = build_field_proposals("000026", decision, {"ipi": None, "ex_tipi": None})
|
||||
assert all(p["field"] != "EX_TIPI" for p in proposals)
|
||||
|
||||
|
||||
def test_ex_tipi_concrete_single_candidate_change_required():
|
||||
decision = _decision(
|
||||
ipi_status="IPI_CANDIDATES",
|
||||
classification={**_decision()["classification"], "ipi": None, "ipi_candidates": [{"ncm": "07069000", "ex": "01", "description": "specific", "rate": "5"}]},
|
||||
)
|
||||
proposals = build_field_proposals("000004", decision, {"ex_tipi": None})
|
||||
ex = next(p for p in proposals if p["field"] == "EX_TIPI")
|
||||
assert ex["status"] == "CHANGE_REQUIRED"
|
||||
assert ex["proposed_value"] == "01"
|
||||
assert ex["write_eligible"] is True
|
||||
|
||||
|
||||
def test_ex_tipi_multiple_candidates_is_candidate_not_eligible():
|
||||
decision = _decision(
|
||||
ipi_status="IPI_CANDIDATES",
|
||||
classification={**_decision()["classification"], "ipi": None, "ipi_candidates": [
|
||||
{"ncm": "07069000", "ex": "01", "description": "a", "rate": "5"},
|
||||
{"ncm": "07069000", "ex": "02", "description": "b", "rate": "7"},
|
||||
]},
|
||||
)
|
||||
proposals = build_field_proposals("000004", decision, {"ex_tipi": None})
|
||||
ex = next(p for p in proposals if p["field"] == "EX_TIPI")
|
||||
assert ex["status"] == "CANDIDATE"
|
||||
assert ex["proposed_value"] is None
|
||||
assert ex["write_eligible"] is False
|
||||
|
||||
|
||||
def test_cest_confirmed_change_required():
|
||||
decision = _decision(cest_status="CEST_CONFIRMED", classification={**_decision()["classification"], "cest": ["17.087.02"]})
|
||||
proposals = build_field_proposals("000018", decision, {"cest": ""})
|
||||
cest = next(p for p in proposals if p["field"] == "CEST")
|
||||
assert cest["status"] == "CHANGE_REQUIRED"
|
||||
assert cest["write_eligible"] is True
|
||||
assert cest["proposed_value"] == "17.087.02"
|
||||
|
||||
|
||||
def test_cest_candidates_not_eligible():
|
||||
decision = _decision(cest_status="CEST_CANDIDATES")
|
||||
proposals = build_field_proposals("000024", decision, {"cest": ""})
|
||||
cest = next(p for p in proposals if p["field"] == "CEST")
|
||||
assert cest["status"] == "CANDIDATE"
|
||||
assert cest["proposed_value"] is None
|
||||
assert cest["write_eligible"] is False
|
||||
|
||||
|
||||
def test_cest_description_mismatch_not_eligible():
|
||||
decision = _decision(cest_status="CEST_DESCRIPTION_MISMATCH")
|
||||
proposals = build_field_proposals("000018", decision, {"cest": ""})
|
||||
cest = next(p for p in proposals if p["field"] == "CEST")
|
||||
assert cest["status"] == "UNRESOLVED"
|
||||
assert cest["reason_code"] == "CEST_DESCRIPTION_SCORE_BELOW_THRESHOLD"
|
||||
assert cest["write_eligible"] is False
|
||||
|
||||
|
||||
def test_legacy_ncm_review_no_invented_successor():
|
||||
decision = _decision(status="LEGACY_NCM", classification={**_decision()["classification"], "ncm": "02071400"})
|
||||
proposals = build_field_proposals("000016", decision, {"ipi": "3.25", "cest": ""})
|
||||
assert len(proposals) == 1
|
||||
row = proposals[0]
|
||||
assert row["field"] == "CLASSIFICACAO_FISCAL"
|
||||
assert row["status"] == "LEGACY_NCM_REVIEW"
|
||||
assert row["proposed_value"] is None
|
||||
assert row["write_eligible"] is False
|
||||
|
||||
|
||||
def test_mva_never_produces_a_write_proposal():
|
||||
decision = _decision(ba_st_mva_status="MVA_CANDIDATES", classification={**_decision()["classification"], "ba_st_mva_candidates": [{"item": "1"}]})
|
||||
proposals = build_field_proposals("000024", decision, {"cest": ""})
|
||||
assert all(p["field"] != "BA_ST_MVA" for p in proposals)
|
||||
evidence = mva_evidence("000024", decision)
|
||||
assert evidence["status"] == "MVA_CANDIDATES"
|
||||
assert evidence["candidates"]
|
||||
|
||||
|
||||
def test_split_by_eligibility():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {})
|
||||
split = split_by_eligibility(proposals)
|
||||
assert split["blocked"] == proposals
|
||||
assert split["eligible"] == []
|
||||
|
||||
|
||||
def test_execute_refuses_non_eligible_proposal():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {})
|
||||
with pytest.raises(PermissionError):
|
||||
execute(proposals)
|
||||
|
||||
|
||||
def test_execute_never_writes_even_when_eligible():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {"ipi": "0"})
|
||||
eligible_only = split_by_eligibility(proposals)["eligible"]
|
||||
assert eligible_only
|
||||
with pytest.raises(NotImplementedError):
|
||||
execute(eligible_only)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Historical evidence -> KB: temporal validity, lineage independence, conflicts, promotion audit, idempotent import. No provider is ever contacted."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.enrichment import history, kb
|
||||
from central.app.enrichment.resolver import resolve
|
||||
from central.app.enrichment.quota import ProviderGateway
|
||||
from central.app.models import KbKnowledge, KbProduct, KbPromotionAudit
|
||||
from central.tests.test_product_enrichment_resolver import COSMOS, Fake, G1, G2, G3, env, gtin, seed # noqa: F401
|
||||
|
||||
G = "0" + G1 # 14-digit key
|
||||
|
||||
|
||||
def staging(client, gtin13, ncm, *, master=True, years=None, nfe=0, desc="REFRIGERANTE COLA LATA 350ML", code="1"):
|
||||
return {"client": client, "master": [{"code": code, "desc": desc, "gtin": gtin13, "codbarra": "", "ncm": ncm if master else "", "cest": ""}],
|
||||
"nfce": [{"code": code, "year": y, "ncm": ncm, "cest": "", "n": n} for y, n in (years or {}).items()], "nfe": [{"code": code, "codbarra": gtin13, "ncm": ncm, "desc": desc, "n": nfe}] if nfe else [], "barcodes": []}
|
||||
|
||||
|
||||
def run(session, stagings, dry=False):
|
||||
obs = history.build_observations(stagings)
|
||||
cur = history.official_sets(session)
|
||||
return history.apply(session, obs, lambda v: v in cur, dry_run=dry)
|
||||
|
||||
|
||||
def status(session, key=G):
|
||||
p = session.scalar(select(KbProduct).where(KbProduct.gtin == key))
|
||||
cur = history.official_sets(session)
|
||||
return kb.evaluate(session, p, "ncm", lambda v: v in cur)
|
||||
|
||||
|
||||
def test_single_client_multi_year_history_is_corroborated_never_verified(env):
|
||||
r = run(env, [staging("A", G1, "22021000", years={2023: 300, 2024: 700, 2025: 900})])
|
||||
assert r["by_status"] == {"CORROBORATED": 1} and r["WOULD_VERIFY"] == 0
|
||||
assert env.scalars(select(KbPromotionAudit)).all() == []
|
||||
|
||||
|
||||
def test_master_plus_documents_of_same_erp_are_one_family(env):
|
||||
run(env, [staging("A", G1, "22021000", master=True, years={2025: 5})]) # few docs, one year: weak
|
||||
assert status(env)["status"] == kb.OBSERVED and status(env)["families"] == []
|
||||
|
||||
|
||||
def test_two_clients_corroborate_but_three_clients_alone_do_not_verify(env):
|
||||
r = run(env, [staging(c, G1, "22021000", years={2024: 2}) for c in ("A", "B", "C")])
|
||||
assert r["by_status"] == {"CORROBORATED": 1} and r["multi_client"] == 1 and r["WOULD_VERIFY"] == 0
|
||||
|
||||
|
||||
def test_history_plus_independent_provider_verifies_and_is_audited(env):
|
||||
run(env, [staging("A", G1, "22021000", years={2023: 50, 2024: 60})])
|
||||
p = env.scalar(select(KbProduct).where(KbProduct.gtin == G))
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "provider:oscbr", family="PROVIDER:oscbr")
|
||||
ev = status(env)
|
||||
assert ev["status"] == kb.VERIFIED_KB and set(ev["families"]) == {"PROVIDER:oscbr", "CLIENT_DATA"}
|
||||
audit = env.scalars(select(KbPromotionAudit)).one()
|
||||
assert audit.new_status == "VERIFIED_KB" and audit.rule_version == kb.RULE_VERSION and audit.official_dataset["sha256"] and audit.sources["documents"] == 110
|
||||
status(env)
|
||||
assert len(env.scalars(select(KbPromotionAudit)).all()) == 1 # no duplicate audit
|
||||
|
||||
|
||||
def test_different_current_ncms_are_a_conflict_not_majority(env):
|
||||
r = run(env, [staging("A", G1, "22021000", years={2024: 900, 2025: 900}), staging("B", G1, "19059090", years={2024: 5, 2025: 5}), staging("C", G1, "22021000", years={2024: 900})])
|
||||
assert r["CONFLICTS"] == 1
|
||||
res = resolve(env, ProviderGateway(env, []), gtin_raw=G1, description="REFRIGERANTE COLA LATA 350ML", current={}, allow_external=False)
|
||||
assert res["state"] == "CONFLICT" and res["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "CONFLICT"
|
||||
|
||||
|
||||
def test_expired_ncm_is_history_not_a_candidate(env):
|
||||
run(env, [staging("A", G1, "11111111", years={2019: 900, 2020: 900})]) # expired in the fixture dataset
|
||||
ev = status(env)
|
||||
assert ev["value"] is None and ev["status"] == kb.OBSERVED
|
||||
row = env.scalar(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm"))
|
||||
assert row.evidence["info"]["historical_state"] == "HISTORICAL_EXPIRED" and row.status == kb.STALE
|
||||
|
||||
|
||||
def test_ncm_changed_over_time_prefers_current(env):
|
||||
run(env, [staging("A", G1, "11111111", years={2019: 900, 2020: 900}), staging("A", G1, "22021000", master=False, years={2024: 50, 2025: 60})])
|
||||
ev = status(env)
|
||||
assert ev["value"] == "22021000" and ev["status"] == kb.CORROBORATED
|
||||
|
||||
|
||||
def test_stale_ncm_with_equal_recency_does_not_count_as_stable(env):
|
||||
run(env, [staging("A", G1, "22021000", master=False, years={2024: 50, 2025: 60}), staging("B", G1, "11111111", master=False, years={2025: 500})])
|
||||
assert status(env)["status"] in (kb.OBSERVED,) # the old code is as recent: not stable, so not even corroborated
|
||||
|
||||
|
||||
def test_import_is_idempotent_and_dry_run_writes_nothing(env):
|
||||
st = [staging("A", G1, "22021000", years={2023: 300, 2024: 700}), staging("B", G3, "19059090", desc="BISCOITO RECHEADO 130G", code="9", years={2024: 30, 2025: 30})]
|
||||
dry = run(env, st, dry=True)
|
||||
assert env.scalars(select(KbProduct)).all() == []
|
||||
a, b = run(env, st), run(env, st)
|
||||
assert a["by_status"] == b["by_status"] == dry["by_status"]
|
||||
assert len(env.scalars(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm")).all()) == 2
|
||||
row = env.scalar(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.value == "22021000"))
|
||||
assert row.evidence["lineages"]["A"]["docs"] == 1000 # replaced, not doubled
|
||||
|
||||
|
||||
def test_aliases_and_no_company_tax_data_stored(env):
|
||||
run(env, [staging("A", G1, "22021000", years={2024: 30, 2025: 30}, desc="COCA COLA LT 350"), staging("B", G1, "22021000", master=False, desc="REFRIG COCA-COLA 350ML", code="2", years={2025: 5})])
|
||||
p = env.scalar(select(KbProduct).where(KbProduct.gtin == G))
|
||||
aliases = {r.value for r in env.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == p.id, KbKnowledge.knowledge_key == "alias"))}
|
||||
assert len(aliases) >= 2
|
||||
blob = repr([r.evidence for r in env.scalars(select(KbKnowledge))]).upper()
|
||||
assert "CFOP" not in blob and "CSOSN" not in blob and "CRT" not in blob
|
||||
|
||||
|
||||
def test_kb_hit_makes_zero_external_requests(env):
|
||||
run(env, [staging("A", G1, "22021000", years={2023: 50, 2024: 60})])
|
||||
p = env.scalar(select(KbProduct).where(KbProduct.gtin == G))
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "provider:oscbr", family="PROVIDER:oscbr")
|
||||
env.commit()
|
||||
spy = Fake("cosmos", COSMOS(), True)
|
||||
r = resolve(env, ProviderGateway(env, [spy]), gtin_raw=G1, description="REFRIGERANTE COLA LATA 350ML", current={})
|
||||
assert r["state"] == "EXACT_GTIN_VERIFIED" and spy.calls == 0 and r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE"
|
||||
|
||||
|
||||
def test_revalidation_marks_stale_review_and_keeps_knowledge(env):
|
||||
run(env, [staging("A", G1, "22021000", years={2023: 50, 2024: 60})])
|
||||
p = env.scalar(select(KbProduct).where(KbProduct.gtin == G))
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "provider:oscbr", family="PROVIDER:oscbr")
|
||||
assert status(env)["status"] == kb.VERIFIED_KB
|
||||
out = kb.revalidate(env, lambda v: True)
|
||||
assert out["stale"] == 0 and status(env)["status"] == kb.VERIFIED_KB # NCM still vigente: stays valid
|
||||
out = kb.revalidate(env, lambda v: False) # the official dataset dropped it
|
||||
row = env.scalar(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm"))
|
||||
assert out["stale"] == 1 and row.status == kb.STALE and row.evidence["info"]["stale_reason"] == "STALE_REVIEW_REQUIRED"
|
||||
assert [a.new_status for a in env.scalars(select(KbPromotionAudit)).all()] == ["VERIFIED_KB", "STALE"]
|
||||
|
||||
|
||||
def test_provider_disagreeing_with_verified_kb_creates_conflict_not_overwrite(env):
|
||||
run(env, [staging("A", G1, "22021000", years={2023: 50, 2024: 60})])
|
||||
p = env.scalar(select(KbProduct).where(KbProduct.gtin == G))
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "provider:oscbr", family="PROVIDER:oscbr")
|
||||
assert status(env)["status"] == kb.VERIFIED_KB
|
||||
kb.add_evidence(env, p, "ncm", "19059090", "provider:cosmos", family="PROVIDER:cosmos")
|
||||
ev = status(env)
|
||||
assert ev["status"] == kb.CONFLICT and ev["value"] is None # write blocked until a human resolves it
|
||||
|
||||
|
||||
def test_aliases_from_history_enable_name_only_match_and_generic_names_stay_ambiguous(env):
|
||||
run(env, [staging("A", G1, "22021000", years={2023: 50, 2024: 60}, desc="COCA-COLA ORIGINAL LATA 350 ML")])
|
||||
p = env.scalar(select(KbProduct).where(KbProduct.gtin == G))
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "provider:oscbr", family="PROVIDER:oscbr")
|
||||
env.commit()
|
||||
r = resolve(env, ProviderGateway(env, []), gtin_raw="", description="COCA COLA LATA 350ML", current={})
|
||||
assert r["state"] == "EXACT_KB_NAME_VERIFIED" and r["fields"]["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
|
||||
g = resolve(env, ProviderGateway(env, []), gtin_raw="", description="REFRIGERANTE", current={})
|
||||
assert g["state"] == "AMBIGUOUS" and all(f["status"] != "AUTO_FILL_SAFE" for f in g["fields"].values())
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Visible status, re-evaluation (panel / Reprocessar / Sincronizar / KB decision), operator attestation, deterministic name-only match, RTC != Enrichment."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.enrichment import api as enrich_api, history, kb, status
|
||||
from central.app.enrichment.quota import ProviderGateway
|
||||
from central.app.enrichment.resolver import resolve
|
||||
from central.app.jobs import create_job
|
||||
from central.app.models import Agent, AuditEvent, Installation, KbKnowledge, KbProduct, KbPromotionAudit, ProductEnrichment
|
||||
from central.tests.test_control_panel_pages import _panel_client
|
||||
from central.tests.test_enrichment_history import G, run as seed_history, staging
|
||||
from central.tests.test_product_enrichment_api import H, _second_agent, resolve as api_resolve, setup
|
||||
from central.tests.test_product_enrichment_resolver import COSMOS, Fake, G1, env, gtin # noqa: F401
|
||||
from central.tests.test_rtc_security import _become, _post
|
||||
|
||||
|
||||
def _kb_conflict(env):
|
||||
seed_history(env, [staging("RINCAO", G1, "19059090", years={2024: 900, 2025: 900}, desc="COCA LATA 350 ML"), staging("UNIAO", G1, "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA LATA 350 ML")])
|
||||
|
||||
|
||||
def test_attestation_resolves_a_conflict_with_audit_and_only_among_candidates(env):
|
||||
_kb_conflict(env)
|
||||
cur = history.official_sets(env)
|
||||
p = env.scalar(select(KbProduct).where(KbProduct.gtin == G))
|
||||
assert kb.evaluate(env, p, "ncm", lambda v: v in cur)["status"] == kb.CONFLICT
|
||||
for bad, why in (("21069090", "NCM_IS_NOT_A_CANDIDATE_OF_THIS_GTIN"), ("22030000x", "NCM_IS_NOT_A_CANDIDATE_OF_THIS_GTIN")):
|
||||
try:
|
||||
kb.attest(env, G, bad, "Rafael", lambda v: v in cur)
|
||||
assert False
|
||||
except ValueError as error:
|
||||
assert str(error) == why
|
||||
res = kb.attest(env, G, "22021000", "Rafael", lambda v: v in cur, note="lata 350ml = refrigerante")
|
||||
assert res["status"] == kb.VERIFIED_KB and res["value"] == "22021000" and set(res["families"]) == {"OPERATOR_ATTESTATION", "CLIENT_DATA"}
|
||||
audit = env.scalars(select(KbPromotionAudit)).one()
|
||||
assert audit.sources["operator"] == "Rafael" and audit.new_status == "VERIFIED_KB"
|
||||
rejected = env.scalar(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.value == "19059090"))
|
||||
assert rejected.status == kb.STALE and rejected.evidence["info"]["historical_state"] == "REJECTED_BY_OPERATOR"
|
||||
|
||||
|
||||
def test_name_only_match_is_deterministic_and_conservative(env):
|
||||
_kb_conflict(env)
|
||||
cur = history.official_sets(env)
|
||||
kb.attest(env, G, "22021000", "Rafael", lambda v: v in cur)
|
||||
zero = gtin("789490070001")
|
||||
seed_history(env, [staging("UNIAO", zero, "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA ZERO LATA 350 ML", code="9")])
|
||||
env.commit()
|
||||
r = resolve(env, ProviderGateway(env, []), gtin_raw="", description="COCA-COLA ORIGINAL LATA 350 ML", current={"CLASSIFICACAO_FISCAL": "00000000"})
|
||||
assert r["state"] == "EXACT_KB_NAME_VERIFIED" and r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE" and r["fields"]["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
|
||||
ambiguous = resolve(env, ProviderGateway(env, []), gtin_raw="", description="COCA COLA ZERO LATA 350 ML", current={})
|
||||
assert ambiguous["fields"]["CLASSIFICACAO_FISCAL"]["status"] != "AUTO_FILL_SAFE" # ZERO is not a neutral extra word: no cross-match
|
||||
generic = resolve(env, ProviderGateway(env, []), gtin_raw="", description="REFRIGERANTE", current={})
|
||||
assert generic["state"] == "AMBIGUOUS"
|
||||
assert not kb.alias_match(frozenset({"COCA", "COLA", "LATA", "350", "ML"}), frozenset({"COCA", "COLA", "ZERO", "LATA", "350", "ML"}))
|
||||
assert kb.alias_match(frozenset({"COCA", "COLA", "LATA", "350", "ML"}), frozenset({"REFRIGERANTE", "COCA", "COLA", "LATA", "350", "ML"}))
|
||||
|
||||
|
||||
def test_every_tracked_product_has_a_visible_status(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
out = api_resolve(client, "N1", "", "COCA-COLA ORIGINAL LATA 350 ML", {"CLASSIFICACAO_FISCAL": "00000000"})
|
||||
with Session(engine) as s:
|
||||
row = s.scalars(select(ProductEnrichment)).one()
|
||||
assert row.ui_status == "EAN_AUSENTE" and row.reason_code == "NAME_ONLY_NO_VERIFIED_MATCH" and row.last_attempt_at is not None
|
||||
assert out["fields"]["CLASSIFICACAO_FISCAL"]["technical_placeholder"] == "00000000"
|
||||
generic = api_resolve(client, "N2", "", "REFRIGERANTE", {})
|
||||
invalid = api_resolve(client, "N3", "7891000100104", "PRODUTO X", {})
|
||||
with Session(engine) as s:
|
||||
st = {r.product_code: r.ui_status for r in s.scalars(select(ProductEnrichment))}
|
||||
assert st["N2"] == "NOME_AMBIGUO" and st["N3"] == "EAN_INVALIDO"
|
||||
assert all(status.LABELS.get(v) for v in st.values())
|
||||
from central.tests.test_product_enrichment_resolver import G1 as g
|
||||
done = api_resolve(client, "N4", g, "REFRIGERANTE COLA LATA 350ML", {})
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(ProductEnrichment).where(ProductEnrichment.product_code == "N4")).one().ui_status == "PRONTO_PARA_PREENCHER"
|
||||
client.post("/v1/products/enrichment/applied", json={"product_code": "N4", "fields": [{"field": "CLASSIFICACAO_FISCAL", "outcome": "APPLIED", "old": "00000000", "new": "22021000"}]}, headers=H)
|
||||
client.post("/v1/products/enrichment/applied", json={"product_code": "N1", "fields": [{"field": "CLASSIFICACAO_FISCAL", "outcome": "ABORTED", "reason": "STALE_CONFLICT"}]}, headers=H)
|
||||
with Session(engine) as s:
|
||||
st = {r.product_code: (r.ui_status, r.reason_code) for r in s.scalars(select(ProductEnrichment))}
|
||||
assert st["N4"][0] == "ENRIQUECIDO" and st["N1"] == ("WRITE_FAILED", "STALE_CONFLICT")
|
||||
|
||||
|
||||
def test_reprocess_sync_and_panel_flag_only_unresolved_products_for_reevaluation(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
api_resolve(client, "U1", "", "COCA-COLA ORIGINAL LATA 350 ML", {"CLASSIFICACAO_FISCAL": "00000000"})
|
||||
api_resolve(client, "E1", G1[:0] or gtin("789100010010"), "REFRIGERANTE COLA LATA 350ML", {})
|
||||
client.post("/v1/products/enrichment/applied", json={"product_code": "E1", "fields": [{"field": "CLASSIFICACAO_FISCAL", "outcome": "APPLIED", "old": None, "new": "22021000"}]}, headers=H)
|
||||
assert client.get("/v1/products/enrichment/pending", headers=H).json() == {"codes": []}
|
||||
with Session(engine) as s:
|
||||
agent = s.scalars(select(Agent)).first()
|
||||
for kind in ("REPROCESS_ALL", "SYNC_INCREMENTAL"):
|
||||
create_job(s, agent, kind, {})
|
||||
assert s.scalars(select(ProductEnrichment).where(ProductEnrichment.reevaluate.is_(True))).all()[0].product_code == "U1"
|
||||
for r in s.scalars(select(ProductEnrichment)):
|
||||
r.reevaluate = False
|
||||
s.commit()
|
||||
for j in s.scalars(select(__import__("central.app.models", fromlist=["Job"]).Job)):
|
||||
j.status = "COMPLETED"
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
create_job(s, s.scalars(select(Agent)).first(), "REPROCESS_PRODUCTS", {"codigos": ["U1"]})
|
||||
assert client.get("/v1/products/enrichment/pending", headers=H).json() == {"codes": ["U1"]} # the enriched product E1 is never re-evaluated
|
||||
api_resolve(client, "U1", "", "COCA-COLA ORIGINAL LATA 350 ML", {"CLASSIFICACAO_FISCAL": "00000000"})
|
||||
assert client.get("/v1/products/enrichment/pending", headers=H).json() == {"codes": []} # consumed by the new evaluation
|
||||
|
||||
|
||||
def test_panel_reevaluate_and_attest_routes_are_csrf_admin_and_audited(monkeypatch):
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as s:
|
||||
seed_history(s, [staging("RINCAO", G1, "19059090", years={2024: 900, 2025: 900}, desc="COCA LATA 350 ML"), staging("UNIAO", G1, "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA LATA 350 ML")])
|
||||
from central.tests.test_product_enrichment_resolver import seed
|
||||
seed(s)
|
||||
i = s.scalars(select(Installation)).one()
|
||||
i.enrichment_mode, i.features_configured = "ENABLED", True
|
||||
s.commit()
|
||||
_become(client, "PADRAO")
|
||||
assert _post(client, "/kb/attest", gtin="0" + G1, ncm="22021000").status_code == 403
|
||||
_become(client, "ADMIN")
|
||||
assert client.post("/kb/attest", data={"csrf_token": "bad", "gtin": "0" + G1, "ncm": "22021000"}, follow_redirects=False).status_code in (400, 403)
|
||||
assert _post(client, "/kb/attest", gtin="0" + G1, ncm="21069090").status_code == 422
|
||||
assert _post(client, "/kb/attest", gtin="0" + G1, ncm="22021000").status_code == 303
|
||||
assert _post(client, f"/agents/{agent_id}/enrichment/reevaluate", code="").status_code == 303
|
||||
with client._session_factory() as s:
|
||||
acts = [e.action for e in s.scalars(select(AuditEvent))]
|
||||
assert "KB_NCM_ATTESTED" in acts and "KB_ATTEST_DENIED" in acts and "ENRICHMENT_REEVALUATE" in acts
|
||||
page = client.get("/?kb_q=COCA COLA LATA 350 ML").text
|
||||
assert "VERIFIED_KB" in page and "decidido por" in page
|
||||
|
||||
|
||||
def test_rtc_mode_and_enrichment_mode_are_independent_switches_with_distinct_labels():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
assert _post(client, f"/agents/{agent_id}/features", enrichment_mode="DRY_RUN", corrector_enabled="1").status_code == 303
|
||||
with client._session_factory() as s:
|
||||
i = s.scalars(select(Installation)).one()
|
||||
assert i.enrichment_mode == "DRY_RUN" and i.rtc_write_mode == "DISABLED"
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="DRY_RUN").status_code == 303
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="ENABLED", confirm="PREENCHER").status_code == 303
|
||||
with client._session_factory() as s:
|
||||
i = s.scalars(select(Installation)).one()
|
||||
assert i.rtc_write_mode == "ENABLED" and i.enrichment_mode == "DRY_RUN" # turning cClassTrib to Automatic never touches the enrichment
|
||||
page = client.get(f"/agents/{agent_id}").text
|
||||
assert "NÃO é o preenchimento RTC" in page and "Enriquecimento de produtos novos" in page
|
||||
@@ -0,0 +1,38 @@
|
||||
from central.app.evidence_aggregation import Observation, aggregate_observations
|
||||
from scripts.analyze_inbound_observations import summarize_rows
|
||||
|
||||
|
||||
def test_two_suppliers_agree_without_conflict():
|
||||
rows = [
|
||||
Observation("P1", "origin", "0", "S1", "D1", "2026-01-01"),
|
||||
Observation("P1", "origin", "0", "S2", "D2", "2026-02-01"),
|
||||
]
|
||||
|
||||
result = aggregate_observations(rows)[("P1", "origin")]
|
||||
|
||||
assert result["observation_count"] == 2
|
||||
assert result["supplier_count"] == 2
|
||||
assert result["agreement_rate"] == 1.0
|
||||
assert result["strong_evidence"] is True
|
||||
assert result["conflict_count"] == 0
|
||||
|
||||
|
||||
def test_supplier_disagreement_is_conflict_not_majority():
|
||||
rows = [
|
||||
Observation("P1", "origin", "0", "S1", "D1", "2026-01-01"),
|
||||
Observation("P1", "origin", "0", "S2", "D2", "2026-02-01"),
|
||||
Observation("P1", "origin", "1", "S3", "D3", "2026-03-01"),
|
||||
]
|
||||
|
||||
result = aggregate_observations(rows)[("P1", "origin")]
|
||||
|
||||
assert result["conflict_count"] == 1
|
||||
assert result["strong_evidence"] is False
|
||||
assert result["value"] is None
|
||||
|
||||
|
||||
def test_inbound_rows_become_supplier_observations_without_raw_identity():
|
||||
result = summarize_rows([("P1", "S1", "2026-01-01", "12345678", "1102", "060")])
|
||||
|
||||
assert result["observation_count"] == 3
|
||||
assert result["sources"] == ["SUPPLIER_OBSERVATION"]
|
||||
@@ -0,0 +1,67 @@
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from central.app.ai.fake_provider import DeterministicFakeSemanticProvider
|
||||
from central.app.ai.llm_provider import LlmAnswerError, LlmEvalItem
|
||||
from central.app.ai.provider import ProviderContractError, ProviderTransportError
|
||||
|
||||
|
||||
def _item(work_item_id="w1"):
|
||||
return LlmEvalItem(
|
||||
work_item_id=work_item_id,
|
||||
description="AGUA MINERAL 500ML",
|
||||
normalized_description="AGUA MINERAL 500ML",
|
||||
brand=None,
|
||||
unit="ML",
|
||||
segment="mercado",
|
||||
candidates=[("22011000", "AGUAS MINERAIS", 0.9), ("22019000", "OUTRAS AGUAS", 0.4)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scenario", "selected", "abstain"),
|
||||
[
|
||||
("CORRECT", "22011000", False),
|
||||
("WRONG", "22019000", False),
|
||||
("ABSTAIN", None, True),
|
||||
],
|
||||
)
|
||||
def test_fake_provider_returns_deterministic_valid_answers(scenario, selected, abstain):
|
||||
provider = DeterministicFakeSemanticProvider()
|
||||
|
||||
answers = provider.select_batch([_item()], scenario=scenario)
|
||||
|
||||
assert answers == provider.select_batch([_item()], scenario=scenario)
|
||||
assert answers[0]["work_item_id"] == "w1"
|
||||
assert answers[0]["selected_ncm"] == selected
|
||||
assert answers[0]["abstain"] is abstain
|
||||
assert provider.calls == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["INVALID_JSON", "MISSING_ITEM", "DUPLICATE_ITEM", "OUT_OF_CANDIDATE"])
|
||||
def test_fake_provider_rejects_contract_scenarios_without_mutating_items(scenario):
|
||||
provider = DeterministicFakeSemanticProvider()
|
||||
items = [_item("w1"), _item("w2")]
|
||||
original = copy.deepcopy(items)
|
||||
|
||||
with pytest.raises((LlmAnswerError, ProviderContractError)):
|
||||
provider.select_batch(items, scenario=scenario)
|
||||
|
||||
assert items == original
|
||||
assert provider.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["TIMEOUT", "429", "500"])
|
||||
def test_fake_provider_raises_typed_transport_errors(scenario):
|
||||
provider = DeterministicFakeSemanticProvider()
|
||||
|
||||
with pytest.raises(ProviderTransportError):
|
||||
provider.select_batch([_item()], scenario=scenario)
|
||||
|
||||
assert provider.calls == 1
|
||||
|
||||
|
||||
def test_fake_provider_rejects_unknown_scenarios():
|
||||
with pytest.raises(ValueError):
|
||||
DeterministicFakeSemanticProvider().select_batch([_item()], scenario="UNKNOWN")
|
||||
@@ -0,0 +1,349 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from central.app.field_contract import (ALREADY, CHANGE, CONTRACT, MANUAL, NA, WRITE_FIELDS, Context, infer_role, resolve_product)
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
REFS = {"cfop_valid": {"5101", "5102", "5401", "5405", "1102", "1101", "1403", "2102"}, "csosn": {"101", "102", "103", "300", "400", "500"}, "cst_icms": {"00", "20", "40", "41", "60"},
|
||||
"cst_pis": {"01", "49", "98"}, "cst_cofins": {"01", "49", "98"}, "origem": set("012345678"), "fcp_uf": {}, "cbs_rate_2026": None, "cclasstrib_count": 0}
|
||||
CO1 = {"uf": "BA", "crt": "1", "contribuinte_ipi": "N", "company_code": "000001"}
|
||||
CO3 = {"uf": "BA", "crt": "3", "contribuinte_ipi": "S", "company_code": "000001"}
|
||||
|
||||
|
||||
def product(**kw):
|
||||
p = {f: None for f in WRITE_FIELDS}
|
||||
p.update({"CODIGO": "000001", "PRODUTO": "CERVEJA LATA", "CODGRUPO": "000019", "TIPO": "VENDA", "PROD_COZINHA": None, "PRODUTO_SERVICO": None, "CLASSIFICACAO_FISCAL": "22030000",
|
||||
"CFOP": "5102", "CSOSN": "102", "CST": "000", "SITUACAO_TRIBUTARIA": "T", "IAT": "A", "IPPT": "T", "INCIDENCIA_PISCOFINS": "NENHUMA", "PISCOFINS": "N"})
|
||||
p.update(kw)
|
||||
return p
|
||||
|
||||
|
||||
def decision(**kw):
|
||||
d = {"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ipi_status": "IPI_RESOLVED", "ba_st_mva_status": "NO_MVA_MAPPING_FOUND", "dataset_versions": {},
|
||||
"classification": {"ncm": "22030000", "ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9, "rate": "3.9"}, "ipi_candidates": [], "cest": None, "cest_candidates": [], "ba_st_mva": None, "ba_st_mva_candidates": []}}
|
||||
cls = kw.pop("classification", {})
|
||||
d.update(kw)
|
||||
d["classification"] = {**d["classification"], **cls}
|
||||
return d
|
||||
|
||||
|
||||
EV = {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {"000001": True}}
|
||||
|
||||
|
||||
IPI_POP = {"scope": "COMPANY", "scope_key": "", "key": "store_ipi_profile", "value": "POPULATE_REFERENCE_RATE"}
|
||||
|
||||
|
||||
def run(p=None, d=None, co=CO1, ctx=None, ev=EV, manual=None, ipi_profile=IPI_POP):
|
||||
return resolve_product(p or product(), d or decision(), co, REFS, ev, Context((ctx or []) + ([ipi_profile] if ipi_profile else [])), manual_ncm=manual)
|
||||
|
||||
|
||||
def st(res, f):
|
||||
return res[f]["state"], res[f]["proposed"]
|
||||
|
||||
|
||||
def test_every_write_field_has_a_complete_contract():
|
||||
keys = {"ERP_FIELD", "UI_LABEL", "SEMANTICS", "DATA_TYPE", "SOURCE_OF_TRUTH", "REQUIRED_INPUTS", "RESOLUTION_RULE", "VALID_VALUES", "NOT_APPLICABLE_RULE", "MANUAL_INPUT_RULE", "WRITE_ALLOWED", "VALIDATION_RULE", "PROVENANCE"}
|
||||
assert set(CONTRACT) == set(WRITE_FIELDS) and len(WRITE_FIELDS) == 23
|
||||
for f, c in CONTRACT.items():
|
||||
assert keys <= set(c) and all(c[k] not in (None, "", []) for k in keys - {"WRITE_ALLOWED"}), f
|
||||
|
||||
|
||||
def test_only_five_final_states_and_manual_always_names_its_input():
|
||||
res = run()
|
||||
for f in WRITE_FIELDS:
|
||||
assert res[f]["state"] in (ALREADY, CHANGE, NA, MANUAL)
|
||||
if res[f]["state"] == MANUAL:
|
||||
assert res[f]["input_key"]
|
||||
assert res[f]["write_eligible"] == (res[f]["state"] == CHANGE)
|
||||
|
||||
|
||||
def test_ncm_states_and_operator_ncm_reprocessing():
|
||||
bad = run(p=product(CLASSIFICACAO_FISCAL="12345678"), d=decision(status="INVALID_NCM"))
|
||||
assert st(bad, "CLASSIFICACAO_FISCAL")[0] == MANUAL and bad["CLASSIFICACAO_FISCAL"]["input_key"] == "ncm"
|
||||
for f in ("IPI", "EX_TIPI", "CEST", "CFOP", "CSOSN"):
|
||||
assert bad[f]["state"] == MANUAL and bad[f]["reason"] == "BLOCKED_BY_NCM"
|
||||
assert st(run(manual=("22030000", False), p=product(CLASSIFICACAO_FISCAL="12345678"), d=decision(status="INVALID_NCM")), "CLASSIFICACAO_FISCAL")[0] == MANUAL
|
||||
fixed = run(manual=("22030000", True), p=product(CLASSIFICACAO_FISCAL="12345678"), d=decision())
|
||||
assert st(fixed, "CLASSIFICACAO_FISCAL") == (CHANGE, "22030000") and fixed["IPI"]["state"] == CHANGE and fixed["IPI"]["proposed"] == 3.9 # dependents reprocessed with the manual NCM
|
||||
|
||||
|
||||
def test_ipi_rules_never_write_nt_or_blank_and_ask_when_ambiguous():
|
||||
assert st(run(), "IPI") == (CHANGE, 3.9)
|
||||
assert st(run(p=product(IPI=3.9)), "IPI")[0] == ALREADY
|
||||
nt = run(d=decision(classification={"ipi": {"rate_kind": "NON_NUMERIC", "rate_classification": "NT"}}))
|
||||
assert nt["IPI"]["state"] == NA and not nt["IPI"]["write_eligible"]
|
||||
blank = run(d=decision(classification={"ipi": {"rate_kind": "BLANK"}}))
|
||||
assert blank["IPI"]["state"] == NA and "NEVER_ZERO" in blank["IPI"]["reason"]
|
||||
amb = decision(ipi_status="IPI_CANDIDATES", classification={"ipi": None, "ipi_candidates": [{"ex": None, "rate": "5", "description": "base"}, {"ex": "1", "rate": "0", "description": "Chope"}]})
|
||||
r = run(d=amb)
|
||||
assert r["IPI"]["state"] == MANUAL and r["IPI"]["input_key"] == "ex_tipi" and len(r["IPI"]["choices"]) == 2
|
||||
solved = run(d=amb, ctx=[{"scope": "PRODUCT", "scope_key": "000001", "key": "ex_tipi", "value": "NONE"}])
|
||||
assert st(solved, "IPI") == (CHANGE, 5.0) and solved["EX_TIPI"]["state"] == NA
|
||||
|
||||
|
||||
def test_cest_contract():
|
||||
off = [{"code": "03.021.03", "official_description": "Cerveja em lata", "description_score": 1.0}, {"code": "03.021.05", "official_description": "PET", "description_score": 0.5}]
|
||||
conf = run(d=decision(cest_status="CEST_CONFIRMED", classification={"cest": ["03.021.03"], "cest_candidates": off}))
|
||||
assert st(conf, "CEST") == (CHANGE, "0302103")
|
||||
assert run(p=product(CEST="0302103"), d=decision(cest_status="CEST_CANDIDATES", classification={"cest_candidates": off}))["CEST"]["state"] == ALREADY
|
||||
many = run(d=decision(cest_status="CEST_CANDIDATES", classification={"cest_candidates": off}))
|
||||
assert many["CEST"]["state"] == MANUAL and many["CEST"]["input_key"] == "cest" and len(many["CEST"]["choices"]) == 2
|
||||
assert run(d=decision(cest_status="CEST_DESCRIPTION_MISMATCH", classification={"cest_candidates": off}))["CEST"]["state"] == NA
|
||||
assert run()["CEST"]["state"] == NA
|
||||
assert run(p=product(CEST="9999999"))["CEST"]["state"] == MANUAL
|
||||
picked = run(d=decision(cest_status="CEST_CANDIDATES", classification={"cest_candidates": off}), ctx=[{"scope": "NCM", "scope_key": "22030000", "key": "cest", "value": "03.021.05"}])
|
||||
assert st(picked, "CEST") == (CHANGE, "0302105")
|
||||
|
||||
|
||||
def test_cfop_role_and_st_rules():
|
||||
ok = run() # RESALE (stock entries), not subject to ST, declared 5102 == derived
|
||||
assert ok["CFOP"]["state"] == ALREADY and ok["_facts"]["role"] == "RESALE"
|
||||
assert st(run(p=product(CFOP="5405")), "CFOP") == (CHANGE, "5102") # 5405 but proven NOT_SUBJECT
|
||||
assert st(run(p=product(CFOP=None)), "CFOP") == (CHANGE, "5102") # blank filled
|
||||
unk = run(d=decision(ba_st_mva_status="MVA_CANDIDATES"), p=product(CFOP=None)) # ST unknown, blank -> asks
|
||||
assert unk["CFOP"]["state"] == MANUAL and unk["CFOP"]["input_key"] == "st_status"
|
||||
assert run(d=decision(ba_st_mva_status="MVA_CANDIDATES"))["CFOP"]["state"] == ALREADY # declared valid, not contradicted
|
||||
role_unknown = run(ev={**EV, "stock_in": {}}, p=product(CFOP=None))
|
||||
assert role_unknown["CFOP"]["state"] == MANUAL and role_unknown["CFOP"]["input_key"] == "product_role"
|
||||
own = run(ev={**EV, "recipes": {"000001"}}, p=product(CFOP="5102"))
|
||||
assert st(own, "CFOP") == (CHANGE, "5101")
|
||||
assert run(p=product(TIPO="MATERIA-PRIMA"))["CFOP"]["state"] == NA
|
||||
|
||||
|
||||
def test_cfop_entrada_needs_company_entry_scope_then_derives():
|
||||
r = run(p=product(CFOP_ENTRADA=None))
|
||||
assert r["CFOP_ENTRADA"]["state"] == MANUAL and r["CFOP_ENTRADA"]["input_key"] == "entry_scope"
|
||||
r = run(p=product(CFOP_ENTRADA=None), ctx=[{"scope": "COMPANY", "scope_key": "", "key": "entry_scope", "value": "INTRA"}])
|
||||
assert st(r, "CFOP_ENTRADA") == (CHANGE, "1102")
|
||||
assert run(p=product(CFOP_ENTRADA="1102"))["CFOP_ENTRADA"]["state"] == ALREADY
|
||||
|
||||
|
||||
def test_cfop_entrada_mixed_scope_is_operation_dependent_and_never_invents_a_cfop():
|
||||
mixed = [{"scope": "COMPANY", "scope_key": "", "key": "entry_scope", "value": "MIXED_OR_DYNAMIC"}]
|
||||
r = run(p=product(CFOP_ENTRADA=None), ctx=mixed)
|
||||
e = r["CFOP_ENTRADA"]
|
||||
# NA (not MANUAL): the audited entry_scope=MIXED_OR_DYNAMIC decision already answered "why no value" -
|
||||
# this must never block product completeness nor re-ask a question the admin already answered (Missão 2 / Bloco 3.4).
|
||||
assert e["state"] == NA and e["proposed"] is None and e.get("input_key") is None
|
||||
assert e["reason"] == "CFOP_ENTRADA_OPERATION_DEPENDENT_MIXED_SCOPE"
|
||||
r2 = run(p=product(CFOP_ENTRADA="2102"), ctx=mixed)
|
||||
assert r2["CFOP_ENTRADA"]["state"] == ALREADY and r2["CFOP_ENTRADA"]["reason"] == "DECLARED_VALID_OPERATION_DEPENDENT_MIXED_SCOPE"
|
||||
from central.app.context_keys import validate_entry
|
||||
assert validate_entry({"scope": "COMPANY", "key": "entry_scope", "value": "MIXED_OR_DYNAMIC"})[0]["value"] == "MIXED_OR_DYNAMIC"
|
||||
|
||||
|
||||
def test_icms_code_fields_follow_the_regime():
|
||||
r1 = run()
|
||||
assert r1["CST"]["state"] == NA and r1["CST_ENTRADA"]["state"] == NA and r1["CSOSN"]["state"] == ALREADY
|
||||
assert run(p=product(CSOSN=None))["CSOSN"]["state"] == MANUAL
|
||||
assert st(run(p=product(CSOSN=None), ctx=[{"scope": "GROUP", "scope_key": "000019", "key": "icms_treatment", "value": "EXEMPT"}]), "CSOSN") == (CHANGE, "400")
|
||||
assert st(run(p=product(CSOSN="102"), ctx=[{"scope": "GROUP", "scope_key": "000019", "key": "icms_treatment", "value": "ST_WITHHELD"}]), "CSOSN") == (CHANGE, "500")
|
||||
r3 = run(co=CO3, p=product(CSOSN=None))
|
||||
assert r3["CSOSN"]["state"] == NA and r3["CST"]["state"] == ALREADY and r3["ALIQUOTA"]["state"] in (ALREADY, MANUAL)
|
||||
blank = run(co=CO3, p=product(CST=None, COD_ORIGEM_MERCADORIA="0"))
|
||||
assert blank["CST"]["state"] == MANUAL and blank["CST"]["input_key"] == "icms_treatment"
|
||||
filled = run(co=CO3, p=product(CST=None, COD_ORIGEM_MERCADORIA="0"), ctx=[{"scope": "COMPANY", "scope_key": "", "key": "icms_treatment", "value": "TAXED"}])
|
||||
assert st(filled, "CST") == (CHANGE, "000")
|
||||
|
||||
|
||||
def test_aliquota_not_copied_blindly():
|
||||
assert run()["ALIQUOTA"]["state"] == NA # Simples CSOSN 102 highlights no ICMS
|
||||
assert run(p=product(CSOSN="101", ALIQUOTA=None))["ALIQUOTA"]["input_key"] == "credit_rate"
|
||||
assert st(run(co=CO3, p=product(ALIQUOTA="17"), ctx=[{"scope": "COMPANY", "scope_key": "", "key": "icms_rate", "value": "20,5"}]), "ALIQUOTA") == (CHANGE, 20.5)
|
||||
|
||||
|
||||
def test_origin_binding_rules():
|
||||
r0 = run(p=product(COD_ORIGEM_MERCADORIA=None, CST="040"))["COD_ORIGEM_MERCADORIA"] # audit V2: the CST first digit is padding, never origin evidence
|
||||
assert r0["state"] == MANUAL and r0["reason"] == "ORIGIN_NO_EVIDENCE" and r0["proposed"] is None
|
||||
assert run(p=product(COD_ORIGEM_MERCADORIA="0", CST="040"))["COD_ORIGEM_MERCADORIA"]["state"] == ALREADY
|
||||
assert run(p=product(COD_ORIGEM_MERCADORIA="2", CST="040"))["COD_ORIGEM_MERCADORIA"]["state"] == ALREADY # declared origin stays; CST digit is padding
|
||||
assert run(p=product(COD_ORIGEM_MERCADORIA=None, CST="540"))["COD_ORIGEM_MERCADORIA"]["state"] == MANUAL # 5 is not on the SGV screen
|
||||
assert st(run(p=product(COD_ORIGEM_MERCADORIA=None, CST="540"), ctx=[{"scope": "GROUP", "scope_key": "000019", "key": "origin", "value": "1"}]), "COD_ORIGEM_MERCADORIA") == (CHANGE, 1)
|
||||
|
||||
|
||||
def test_cst_ipi_pis_cofins_incidence_iat_ippt():
|
||||
assert run()["CST_IPI"]["state"] == NA
|
||||
assert run(co=CO3)["CST_IPI"]["state"] == MANUAL
|
||||
r = run()
|
||||
assert r["COD_SAIDA_PIS"]["state"] == MANUAL and r["COD_SAIDA_PIS"]["input_key"] == "pis_cofins_profile" and r["ALIQ_COFINS"]["state"] == MANUAL
|
||||
prof = '{"pis_out_cst": "49", "pis_in_cst": "98", "pis_rate": "0", "cofins_out_cst": "49", "cofins_in_cst": "98", "cofins_rate": "0"}'
|
||||
r = run(ctx=[{"scope": "COMPANY", "scope_key": "", "key": "pis_cofins_profile", "value": prof}])
|
||||
assert st(r, "COD_SAIDA_PIS") == (CHANGE, "49") and st(r, "COD_ENTRADA_COFINS") == (CHANGE, "98") and st(r, "ALIQ_PIS") == (CHANGE, "0")
|
||||
bad = run(ctx=[{"scope": "COMPANY", "scope_key": "", "key": "pis_cofins_profile", "value": '{"pis_out_cst": "77"}'}])
|
||||
assert bad["COD_SAIDA_PIS"]["state"] == MANUAL # not in the official CST table
|
||||
assert run(p=product(IAT=None))["IAT"]["input_key"] == "rounding"
|
||||
assert run()["INCIDENCIA_PISCOFINS"]["state"] == ALREADY
|
||||
assert st(run(ev={**EV, "recipes": {"000001"}}), "IPPT") == (CHANGE, "P") and run()["IPPT"]["state"] == ALREADY
|
||||
|
||||
|
||||
def test_pis_cofins_not_applicable_and_operation_time_require_an_audited_admin_decision():
|
||||
"""Missão 2 / Bloco 2.4: NOT_APPLICABLE/OPERATION_TIME for PIS/COFINS must never be inferred (e.g. from emission
|
||||
statistics) - only ever accepted from an explicit, audited FiscalContextEntry(key="pis_cofins_profile")."""
|
||||
pis_cofins_fields = ("COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS")
|
||||
# Sem decisão ADMIN => comportamento antigo (continua bloqueando, pedindo pis_cofins_profile).
|
||||
r = run()
|
||||
for f in pis_cofins_fields:
|
||||
assert r[f]["state"] == MANUAL and r[f]["input_key"] == "pis_cofins_profile"
|
||||
# Com decisão ADMIN NOT_APPLICABLE => NA em todos, nunca escreve, nunca bloqueia completude.
|
||||
na_ctx = [{"scope": "COMPANY", "scope_key": "", "key": "pis_cofins_profile", "value": "NOT_APPLICABLE"}]
|
||||
r_na = run(ctx=na_ctx)
|
||||
for f in pis_cofins_fields:
|
||||
assert r_na[f]["state"] == NA and r_na[f]["proposed"] is None and r_na[f]["reason"] == "ADMIN_DECISION_PIS_COFINS_NOT_APPLICABLE"
|
||||
# Com decisão ADMIN OPERATION_TIME => NA em todos (não bloqueia), motivo distinto, nunca escreve.
|
||||
ot_ctx = [{"scope": "COMPANY", "scope_key": "", "key": "pis_cofins_profile", "value": "OPERATION_TIME"}]
|
||||
r_ot = run(ctx=ot_ctx)
|
||||
for f in pis_cofins_fields:
|
||||
assert r_ot[f]["state"] == NA and r_ot[f]["proposed"] is None and r_ot[f]["reason"] == "ADMIN_DECISION_PIS_COFINS_OPERATION_TIME"
|
||||
# Uma decisão de PROFILE (JSON) continua funcionando exatamente como antes (não regressivo).
|
||||
prof = '{"pis_out_cst": "49", "pis_in_cst": "98", "pis_rate": "0", "cofins_out_cst": "49", "cofins_in_cst": "98", "cofins_rate": "0"}'
|
||||
r_prof = run(ctx=[{"scope": "COMPANY", "scope_key": "", "key": "pis_cofins_profile", "value": prof}])
|
||||
assert st(r_prof, "COD_SAIDA_PIS") == (CHANGE, "49")
|
||||
assert run(p=product(TIPO="MATERIA-PRIMA"))["IPPT"]["state"] == NA
|
||||
|
||||
|
||||
def test_context_hierarchy_product_beats_group_beats_company():
|
||||
ctx = Context([{"scope": "COMPANY", "scope_key": "", "key": "product_role", "value": "RESALE"}, {"scope": "GROUP", "scope_key": "G1", "key": "product_role", "value": "OWN_PRODUCTION"},
|
||||
{"scope": "PRODUCT", "scope_key": "P1", "key": "product_role", "value": "INPUT"}])
|
||||
assert ctx.get("product_role", "P1", "G1") == ("INPUT", "PRODUCT")
|
||||
assert ctx.get("product_role", "P2", "G1") == ("OWN_PRODUCTION", "GROUP")
|
||||
assert ctx.get("product_role", "P2", "G9") == ("RESALE", "COMPANY")
|
||||
assert Context([]).get("product_role", "P2", "G9") == (None, None)
|
||||
|
||||
|
||||
def test_role_inference_is_evidence_based_and_conflicts_stay_unknown():
|
||||
assert infer_role(product(TIPO="MATERIA-PRIMA"), EV, Context([]), "000001", "1")[0] == "INPUT"
|
||||
assert infer_role(product(), {**EV, "stock_in": {}}, Context([]), "000001", "1")[0] == "UNKNOWN"
|
||||
assert infer_role(product(PROD_COZINHA=1), {**EV, "purchases": {"000001": [{}]}}, Context([]), "000001", "1")[0] == "UNKNOWN"
|
||||
assert infer_role(product(PROD_COZINHA=1), EV, Context([]), "000001", "1")[0] == "UNKNOWN" # kitchen flag alone is not proof
|
||||
assert infer_role(product(PROD_COZINHA=1), {**EV, "recipes": {"000001"}}, Context([]), "000001", "1")[0] == "OWN_PRODUCTION"
|
||||
|
||||
|
||||
def test_composed_evidence_needs_a_dominant_majority_never_a_single_occurrence():
|
||||
"""Missão 4 / Bloco A.1: C000032.TIPO=COMPOSICAO must be a deterministic MAJORITY of that
|
||||
product's own movement history to prove OWN_PRODUCTION - real cases audited from Agent 2 (2026-09-28)."""
|
||||
def ev_with(composed, other):
|
||||
return {**EV, "composed_ratio": {"000001": {"composed": composed, "other": other}}}
|
||||
|
||||
# A cerveja: 1 composição vs 19 vendas (5%) - minoria clara, evidência descartada -> cai para o
|
||||
# papel padrão (aqui sem TIPO=VENDA/docs no fixture, então UNKNOWN por falta de outra evidência;
|
||||
# o ponto do teste é que NUNCA vira OWN_PRODUCTION).
|
||||
assert infer_role(product(), ev_with(1, 19), Context([]), "000001", "1")[0] != "OWN_PRODUCTION"
|
||||
r = infer_role(product(TIPO="VENDA"), {**ev_with(1, 19), "stock_in": {"000001": True}}, Context([]), "000001", "1")
|
||||
assert r[0] == "RESALE" # a cerveja, com evidência de venda, é revenda - nunca produção própria
|
||||
|
||||
# O refrigerante/2ª cerveja: 3 composições vs 4 vendas (42,9%) - amostra pequena mas na zona
|
||||
# ambígua -> evidência CONFLITANTE, nunca adivinhada para nenhum dos dois lados.
|
||||
r = infer_role(product(TIPO="VENDA"), {**ev_with(3, 4), "stock_in": {"000001": True}}, Context([]), "000001", "1")
|
||||
assert r[0] == "UNKNOWN" and "MIXED" in r[1][0]
|
||||
|
||||
# O licor: 34 composições vs 22 vendas (60,7%) - maioria real, mas abaixo do limiar dominante
|
||||
# (70%) -> também CONFLITANTE, nunca OWN_PRODUCTION por uma maioria fraca.
|
||||
r = infer_role(product(TIPO="VENDA"), {**ev_with(34, 22), "stock_in": {"000001": True}}, Context([]), "000001", "1")
|
||||
assert r[0] == "UNKNOWN" and "MIXED" in r[1][0]
|
||||
|
||||
# Suco com PROD_COZINHA=1 + RECEITA_INGREDIENTES: evidência forte e DIRETA (não é composição
|
||||
# estatística) - continua OWN_PRODUCTION, o limiar de composição não se aplica a ela.
|
||||
assert infer_role(product(PROD_COZINHA=1), {**EV, "recipes": {"000001"}}, Context([]), "000001", "1")[0] == "OWN_PRODUCTION"
|
||||
|
||||
# Uma maioria REAL e forte (90%, amostra suficiente) ainda prova produção própria.
|
||||
r = infer_role(product(), ev_with(90, 10), Context([]), "000001", "1")
|
||||
assert r[0] == "OWN_PRODUCTION" and r[1] == ["C000032.TIPO=COMPOSICAO"]
|
||||
|
||||
# Amostra insuficiente (< 5 eventos), mesmo 100% composição: não confia num histórico tão raso.
|
||||
r = infer_role(product(TIPO="VENDA"), {**ev_with(2, 0), "stock_in": {"000001": True}}, Context([]), "000001", "1")
|
||||
assert r[0] == "RESALE" # a evidência de composição é ignorada, cai para a evidência de venda real
|
||||
|
||||
# Tilápia/corvina: sem NENHUMA evidência de composição - continua revenda normalmente (o fix
|
||||
# não pode quebrar o caso que já funcionava).
|
||||
r = infer_role(product(TIPO="VENDA", CFOP=None), {**EV, "purchases": {"000001": [{}]}}, Context([]), "000001", "1")
|
||||
assert r[0] == "RESALE"
|
||||
|
||||
|
||||
# ---------------- executor guards ----------------
|
||||
def load_executor():
|
||||
spec = importlib.util.spec_from_file_location("fwe_local", ROOT / "scripts" / "firebird_write_executor.py")
|
||||
m = importlib.util.module_from_spec(spec); sys.modules[spec.name] = m; spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, db):
|
||||
self.db, self.rowcount, self._row = db, 0, None
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
s = " ".join(sql.split())
|
||||
if "RDB$FIELD_TYPE" in s:
|
||||
self._row = (37, 20)
|
||||
elif s.startswith("SELECT") and "FROM C000025" in s:
|
||||
fields = [x.strip() for x in s.split("SELECT")[1].split("FROM")[0].split(",")]
|
||||
row = self.db["rows"].get(params[0])
|
||||
self._row = tuple(row[f] for f in fields) if row else None
|
||||
elif s.startswith("UPDATE"):
|
||||
row = self.db["rows"].get(params[-1])
|
||||
self.rowcount = 0 if row is None else self.db["update_rowcount"]
|
||||
if row is not None and self.rowcount == 1:
|
||||
for f, v in zip([x.split("=")[0].strip() for x in s.split("SET")[1].split("WHERE")[0].split(",")], params[:-1]):
|
||||
self.db["pending"][f] = v
|
||||
|
||||
def fetchone(self):
|
||||
return self._row
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self, rows, update_rowcount=1):
|
||||
self.db = {"rows": rows, "update_rowcount": update_rowcount, "pending": {}}
|
||||
self.rolled_back = self.committed = False
|
||||
|
||||
def cursor(self):
|
||||
return FakeCursor(self.db)
|
||||
|
||||
def commit(self):
|
||||
for row in self.db["rows"].values():
|
||||
row.update(self.db["pending"])
|
||||
self.committed = True
|
||||
|
||||
def rollback(self):
|
||||
self.db["pending"].clear(); self.rolled_back = True
|
||||
|
||||
|
||||
ALLOWED = r"D:\PROJETOS\GTISISTEMAS\Projeto\work_copies\CONSTRUCAO.FDB"
|
||||
|
||||
|
||||
def test_executor_allowlist_is_exact_and_field_set_is_the_contract():
|
||||
ex = load_executor()
|
||||
assert ex._LOCAL_COMPLETION_FIELDS == set(WRITE_FIELDS)
|
||||
allow = ex.load_local_allowlist()
|
||||
assert len(allow) == 5
|
||||
for path in (ALLOWED, r"D:\PROJETOS\GTISISTEMAS\Projeto\TESTESOFICIAIS.FDB"):
|
||||
ex.assert_local_completion_target(path, allow)
|
||||
for bad in (r"D:\PROJETOS\GTISISTEMAS\Banco\GTI.FDB", r"D:\PROJETOS\GTISISTEMAS\Projeto\Banco\OUTRA.FDB", r"D:\PROJETOS\GTISISTEMAS\Projeto\work_copies\CONSTRUCAO.FDB.bak", r"D:\PROJETOS\GTISISTEMAS\Projeto\Banco", r"D:\PROJETOS\GTISISTEMAS\Projeto\Banco\CONSTRUCAO.FDB"):
|
||||
with pytest.raises(ex.WriteRefused):
|
||||
ex.assert_local_completion_target(bad, allow)
|
||||
|
||||
|
||||
def test_guarded_product_update_success_drift_rowcount_and_refusals():
|
||||
ex = load_executor()
|
||||
allow = ex.load_local_allowlist()
|
||||
conn = FakeConn({"000001": {"IPI": None, "CEST": "1234567"}})
|
||||
rec = ex.execute_guarded_product_update(conn, database_path=ALLOWED, product_code="000001", changes={"IPI": (None, 3.9), "CEST": ("1234567", "7654321")}, now="t", allowlist=allow)
|
||||
assert rec["rowcount"] == 1 and rec["fields"]["IPI"]["after"] == 3.9 and conn.committed
|
||||
with pytest.raises(ex.WriteRefused, match="drift"):
|
||||
ex.execute_guarded_product_update(FakeConn({"000001": {"IPI": 1.0}}), database_path=ALLOWED, product_code="000001", changes={"IPI": (None, 3.9)}, now="t", allowlist=allow)
|
||||
c2 = FakeConn({"000001": {"IPI": None}}, update_rowcount=2)
|
||||
with pytest.raises(ex.WriteRefused, match="rowcount"):
|
||||
ex.execute_guarded_product_update(c2, database_path=ALLOWED, product_code="000001", changes={"IPI": (None, 3.9)}, now="t", allowlist=allow)
|
||||
assert c2.rolled_back
|
||||
with pytest.raises(ex.WriteRefused, match="allowlist"):
|
||||
ex.execute_guarded_product_update(conn, database_path=r"D:\PROJETOS\GTISISTEMAS\Banco\GTI.FDB", product_code="000001", changes={"IPI": (None, 1)}, now="t", allowlist=allow)
|
||||
with pytest.raises(ex.WriteRefused, match="contract write target"):
|
||||
ex.execute_guarded_product_update(conn, database_path=ALLOWED, product_code="000001", changes={"CODIGO": ("000001", "9")}, now="t", allowlist=allow)
|
||||
with pytest.raises(ex.WriteRefused, match="null"):
|
||||
ex.execute_guarded_product_update(conn, database_path=ALLOWED, product_code="000001", changes={"IPI": (None, None)}, now="t", allowlist=allow)
|
||||
with pytest.raises(ex.WriteRefused, match="only C000025"):
|
||||
ex.execute_guarded_product_update(conn, database_path=ALLOWED, product_code="000001", changes={"IPI": (None, 1)}, now="t", allowlist=allow, table="NFCE_ITENS")
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.fiscal_completeness import evaluate_field, evaluate_product, load_requiredness_matrix
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_requiredness_matrix_covers_contract_fields_once():
|
||||
contract = json.loads((ROOT / "docs/FISCAL_FIELD_CONTRACT_V1.json").read_text(encoding="utf-8"))
|
||||
matrix = load_requiredness_matrix(ROOT / "docs/FISCAL_REQUIREDNESS_MATRIX_V1.json")
|
||||
|
||||
assert set(matrix["fields"]) == set(contract["fields"])
|
||||
assert len(matrix["fields"]) == 23
|
||||
assert set(matrix["classifications"]) == {
|
||||
"REQUIRED", "CONDITIONAL", "OPTIONAL_REFERENCE", "NOT_APPLICABLE", "ERP_NOT_USED"
|
||||
}
|
||||
|
||||
|
||||
def test_conditional_empty_when_not_triggered_is_not_missing():
|
||||
matrix = load_requiredness_matrix(ROOT / "docs/FISCAL_REQUIREDNESS_MATRIX_V1.json")
|
||||
|
||||
result = evaluate_field("IPI", None, {"contribuinte_ipi": "N", "has_numeric_tipi": False}, matrix)
|
||||
|
||||
assert result["status"] == "CONDITIONAL_NOT_TRIGGERED"
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
def test_required_missing_makes_product_incomplete():
|
||||
matrix = load_requiredness_matrix(ROOT / "docs/FISCAL_REQUIREDNESS_MATRIX_V1.json")
|
||||
|
||||
result = evaluate_product(
|
||||
{"CLASSIFICACAO_FISCAL": None, "CFOP": "5102"},
|
||||
{"product_role": "RESALE", "operation": "SALE"},
|
||||
matrix,
|
||||
)
|
||||
|
||||
assert result["FISCALLY_COMPLETE"] is False
|
||||
assert result["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "REQUIRED_MISSING"
|
||||
|
||||
|
||||
def test_erp_not_used_is_not_missing():
|
||||
matrix = load_requiredness_matrix(ROOT / "docs/FISCAL_REQUIREDNESS_MATRIX_V1.json")
|
||||
|
||||
result = evaluate_field("IAT", None, {"erp_uses_iat": False}, matrix)
|
||||
|
||||
assert result["status"] == "ERP_NOT_SUPPORTED"
|
||||
assert result["valid"] is True
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Post-NCM fiscal completion: wires the existing (previously dormant) field_contract.resolve_product into the live pipeline. Reuses the SAME engine,
|
||||
the SAME FiscalContextEntry table the blocker-resolution workflow already writes to, and company_uf/company_crt already auto-discovered from every
|
||||
heartbeat - no new rule, no guessing."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.enrichment import fiscal_completion as fcomp
|
||||
from central.app.field_contract import WRITE_FIELDS
|
||||
from central.app.models import Agent, Base, CfopEntry, Dataset, FiscalCodeEntry, FiscalContextEntry, Installation, Tenant
|
||||
|
||||
|
||||
def _seed_codes(session, ncm="22030000"):
|
||||
ds = Dataset(kind="NFE_MOC_TABLES", provider="x", sha256="a" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=0)
|
||||
session.add(ds); session.flush()
|
||||
for domain, codes in (("CSOSN", ["101", "102", "103", "300", "400", "500"]), ("CST_ICMS", ["00", "20", "40", "41", "60"]),
|
||||
("CST_PIS", ["01", "49", "98"]), ("CST_COFINS", ["01", "49", "98"]), ("ORIGEM_MERCADORIA", list("012345678"))):
|
||||
for code in codes:
|
||||
session.add(FiscalCodeEntry(dataset_id=ds.id, domain=domain, code=code, description=f"{domain} {code}", source_section="test"))
|
||||
cfop_ds = Dataset(kind="CFOP", provider="x", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=0)
|
||||
session.add(cfop_ds); session.flush()
|
||||
for code in ("5101", "5102", "5401", "1102", "1101"):
|
||||
session.add(CfopEntry(dataset_id=cfop_ds.id, code=code, title=f"CFOP {code}"))
|
||||
session.commit()
|
||||
|
||||
|
||||
def _installation(session, uf="BA", crt="1", company_key="000001", contribuinte_ipi=None):
|
||||
tenant = Tenant(id=1, name="Acme"); session.add(tenant); session.flush()
|
||||
agent = Agent(tenant_id=1, name="a", token_hash="x" * 64); session.add(agent); session.flush()
|
||||
inst = Installation(agent_id=agent.id, tenant_id=1, installation_id="11111111-1111-4111-8111-111111111111", company_uf=uf, company_crt=crt, company_code=company_key,
|
||||
company_contribuinte_ipi=contribuinte_ipi)
|
||||
session.add(inst); session.flush()
|
||||
return inst
|
||||
|
||||
|
||||
def _ctx(session, tenant_id, company_key, entries):
|
||||
for e in entries:
|
||||
session.add(FiscalContextEntry(tenant_id=tenant_id, company_key=company_key, scope=e[0], scope_key=e.get("scope_key", "") if isinstance(e, dict) else "", key=e[1] if not isinstance(e, dict) else e["key"],
|
||||
value=e[2] if not isinstance(e, dict) else e["value"], source="OPERATOR"))
|
||||
session.commit()
|
||||
|
||||
|
||||
def env():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_field_shapes():
|
||||
assert fcomp.is_writable_shape("CFOP", "5102") and not fcomp.is_writable_shape("CFOP", "51A2") and not fcomp.is_writable_shape("CFOP", "510")
|
||||
assert fcomp.is_writable_shape("CST", "000") and fcomp.is_writable_shape("CST", "0000")
|
||||
assert fcomp.is_writable_shape("ALIQUOTA", "17.5") and fcomp.is_writable_shape("ALIQ_PIS", "1,65") and not fcomp.is_writable_shape("ALIQUOTA", "abc")
|
||||
assert fcomp.is_writable_shape("SITUACAO_TRIBUTARIA", "t") and not fcomp.is_writable_shape("SITUACAO_TRIBUTARIA", "X")
|
||||
assert fcomp.is_writable_shape("IAT", "A") and fcomp.is_writable_shape("IPPT", "P") and fcomp.is_writable_shape("PISCOFINS", "N")
|
||||
assert not fcomp.is_writable_shape("CFOP", "")
|
||||
|
||||
|
||||
def test_company_auto_discovered_from_heartbeat_no_guessing():
|
||||
s = env()
|
||||
inst = _installation(s)
|
||||
assert fcomp.build_company(inst) == {"uf": "BA", "crt": "1"}
|
||||
|
||||
|
||||
def test_resolve_fiscal_fields_with_full_context_fills_independent_safe_fields():
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
import json
|
||||
pis_cofins_profile = json.dumps({"pis_in_cst": "01", "pis_out_cst": "01", "pis_rate": "1.65", "cofins_in_cst": "01", "cofins_out_cst": "01", "cofins_rate": "7.6"})
|
||||
_ctx(s, 1, "000001", [
|
||||
("COMPANY", "product_role", "RESALE"), ("COMPANY", "icms_treatment", "TAXED"), ("COMPANY", "origin", "0"),
|
||||
("COMPANY", "pis_cofins_profile", pis_cofins_profile), ("COMPANY", "piscofins", "S"), ("COMPANY", "rounding", "A"),
|
||||
("COMPANY", "store_ipi_profile", "LEAVE_NOT_APPLICABLE"), ("COMPANY", "icms_rate", "17"), ("COMPANY", "entry_scope", "INTRA"),
|
||||
])
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X1", description="CERVEJA LATA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
safe = {f: v["value"] for f, v in out.items() if v["status"] == "AUTO_FILL_SAFE"}
|
||||
assert "CSOSN" in safe and safe["CSOSN"] == "102"
|
||||
assert "PISCOFINS" in safe or out["PISCOFINS"]["status"] == "ALREADY_SET"
|
||||
for f, v in out.items():
|
||||
assert v["status"] in ("AUTO_FILL_SAFE", "ALREADY_SET", "NOT_APPLICABLE", "NEEDS_REVIEW", "INSUFFICIENT_EVIDENCE"), f
|
||||
|
||||
|
||||
def test_field_independence_one_missing_context_does_not_block_others():
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
_ctx(s, 1, "000001", [("COMPANY", "product_role", "RESALE"), ("COMPANY", "rounding", "A")]) # icms_treatment / pis_cofins_profile intentionally missing
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X2", description="CERVEJA LATA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["IAT"]["status"] == "AUTO_FILL_SAFE" and out["IAT"]["value"] == "A" # resolved independently
|
||||
assert out["CSOSN"]["status"] == "NEEDS_REVIEW" and out["CSOSN"]["missing_key"] == "icms_treatment"
|
||||
assert out["COD_ENTRADA_PIS"]["status"] == "NEEDS_REVIEW" and out["COD_ENTRADA_PIS"]["missing_key"] == "pis_cofins_profile"
|
||||
|
||||
|
||||
def test_contribuinte_ipi_auto_discovered_from_installation_wins_over_operator_context():
|
||||
"""Installation.company_contribuinte_ipi (auto-discovered at heartbeat from C000004.CONTRIBUINTE_IPI) is an ERP fact; a FiscalContextEntry
|
||||
operator answer is only a fallback for an Agent version that hasn't reported it yet. ERP fact must win when both exist."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s, contribuinte_ipi="S")
|
||||
_ctx(s, 1, "000001", [("COMPANY", "contribuinte_ipi", "N")]) # stale/wrong operator answer - the ERP fact must override it
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X9", description="MAQUINA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["CST_IPI"]["status"] == "NEEDS_REVIEW" # contribuinte_ipi=S alone doesn't fill CST_IPI (still needs a declared value), but it's no longer NOT_APPLICABLE
|
||||
assert out["CST_IPI"]["reason"] != "contribuinte de IPI não confirmado" # proves the ERP fact (S) was used, not "unknown"
|
||||
|
||||
|
||||
def test_targeted_product_evidence_feeds_role_inference_st_withheld_never_a_full_scan():
|
||||
"""`ev` (firebird_reader.read_product_evidence's targeted, per-product recipes/composed/purchases/stock) reaches field_contract.resolve_product
|
||||
the same way the full-scan `read_evidence` already does for sync jobs - just scoped to ONE product, never the whole catalog."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X10", description="BOLO CASEIRO", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1,
|
||||
ev={"recipes": {"X10"}, "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}})
|
||||
assert out["CFOP"]["status"] == "NEEDS_REVIEW" # OWN_PRODUCTION now provable from the recipe evidence alone - no product_role question, only the CFOP direction/context remains
|
||||
assert out["CFOP"]["missing_key"] != "product_role"
|
||||
|
||||
|
||||
def test_extra_columns_feed_field_contracts_own_role_inference_no_operator_question_needed():
|
||||
"""PRODUTO_SERVICO is a real C000025 column field_contract.infer_role already reads as objective ERP evidence (no ctx.get("product_role")
|
||||
needed). Proves `extra_columns` (the Agent's classification columns) actually reach `resolve_product`: with PRODUTO_SERVICO set, CFOP is
|
||||
blocked on the SERVICE-specific `service_cfop` key, not the generic `product_role` key a fully-unknown product would need."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X7", description="CONSULTORIA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1,
|
||||
extra_columns={"PRODUTO_SERVICO": "1"})
|
||||
assert out["CFOP"]["status"] == "NEEDS_REVIEW" and out["CFOP"]["missing_key"] == "service_cfop"
|
||||
|
||||
|
||||
def test_prod_cozinha_string_from_agent_json_payload_is_normalized_to_int_for_infer_role():
|
||||
"""The Agent sends `current`/extra columns as JSON strings ("1", not 1). field_contract.infer_role compares PROD_COZINHA == 1 (a real
|
||||
Firebird SMALLINT). Without normalization the kitchen-routing signal would silently never fire."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X8", description="PRATO FEITO", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1,
|
||||
extra_columns={"PROD_COZINHA": "1"})
|
||||
assert out["CFOP"]["status"] == "NEEDS_REVIEW" # PROD_COZINHA=1 alone is UNKNOWN (not proof), but it must be READ, not silently ignored as a non-numeric string
|
||||
assert out["CFOP"]["missing_key"] == "product_role"
|
||||
|
||||
|
||||
def test_cst_ipi_unknown_contribuinte_ipi_is_needs_review_not_a_silent_not_applicable():
|
||||
"""C000004.CONTRIBUINTE_IPI is not yet sent at heartbeat - `company` never carries it unless an operator answered the context key. field_contract's
|
||||
own rule reads a missing fact the same as a proven "N" (NOT_APPLICABLE); this module must not let that false negative through silently."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s) # no contribuinte_ipi context entry: genuinely unknown
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X5", description="CERVEJA LATA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["CST_IPI"]["status"] == "NEEDS_REVIEW" and out["CST_IPI"]["missing_key"] == "contribuinte_ipi"
|
||||
_ctx(s, 1, "000001", [("COMPANY", "contribuinte_ipi", "N")])
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X6", description="CERVEJA LATA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["CST_IPI"]["status"] == "NOT_APPLICABLE" # once the fact is genuinely proven "N", the engine's own NA decision is trusted again
|
||||
|
||||
|
||||
def test_not_official_ncm_blocks_everything_with_explicit_reason():
|
||||
s = env()
|
||||
inst = _installation(s)
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X3", description="X", current={}, ncm="00000000", ncm_official=False, installation=inst, tenant_id=1)
|
||||
assert all(v["status"] == "INSUFFICIENT_EVIDENCE" for v in out.values())
|
||||
|
||||
|
||||
def test_existing_value_preserved_not_overwritten():
|
||||
"""CSOSN 500 declares ICMS already withheld by ST - a DIFFERENT treatment than a plain TAXED declaration (CSOSN_FAMILY in field_contract.py
|
||||
maps icms_treatment=ST_WITHHELD -> {"500"} and icms_treatment=TAXED -> {"101","102","103"}). With the matching treatment declared, the engine
|
||||
proves the existing value correct and does not touch it."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
_ctx(s, 1, "000001", [("COMPANY", "product_role", "RESALE"), ("COMPANY", "icms_treatment", "ST_WITHHELD"), ("COMPANY", "origin", "0")])
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X4", description="CERVEJA LATA", current={"CSOSN": "500"}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["CSOSN"]["status"] == "ALREADY_SET"
|
||||
|
||||
|
||||
def test_coverage_status_complete_vs_safe_blocked():
|
||||
complete = {f: {"status": "ALREADY_SET"} for f in fcomp.FISCAL_COMPLETION_FIELDS}
|
||||
assert fcomp.coverage_status(complete) == "COMPLETE"
|
||||
blocked = dict(complete, CFOP={"status": "NEEDS_REVIEW"})
|
||||
assert fcomp.coverage_status(blocked) == "SAFE_BLOCKED"
|
||||
|
||||
|
||||
def test_all_write_fields_covered_by_the_module_except_the_explicit_exclusions():
|
||||
excluded = {"CLASSIFICACAO_FISCAL", "CEST", "IPI", "COD_ORIGEM_MERCADORIA", "EX_TIPI"}
|
||||
assert set(WRITE_FIELDS) - excluded == set(fcomp.FISCAL_COMPLETION_FIELDS)
|
||||
@@ -0,0 +1,50 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.context_keys import validate_entry
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Base
|
||||
|
||||
H = {"Authorization": "Bearer test-token"}
|
||||
|
||||
|
||||
def client():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return TestClient(create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token"))
|
||||
|
||||
|
||||
def test_validation_rules():
|
||||
ok, err = validate_entry({"scope": "group", "scope_key": "000019", "key": "product_role", "value": "resale"})
|
||||
assert err is None and ok["value"] == "RESALE" and ok["scope"] == "GROUP"
|
||||
for bad in ({"scope": "GROUP", "key": "product_role", "value": "RESALE"}, # group without key
|
||||
{"scope": "COMPANY", "key": "product_role", "value": "BOSS"}, # not in enum
|
||||
{"scope": "GROUP", "scope_key": "1", "key": "ncm", "value": "22030000"}, # ncm is product scope
|
||||
{"scope": "PRODUCT", "scope_key": "1", "key": "ncm", "value": "2203"}, # not 8 digits
|
||||
{"scope": "COMPANY", "key": "pis_cofins_profile", "value": "{\"x\": 1}"}, # unknown profile key
|
||||
{"scope": "COMPANY", "key": "icms_rate", "value": "abc"}, {"scope": "COMPANY", "key": "nope", "value": "1"}):
|
||||
assert validate_entry(bad)[1]
|
||||
assert validate_entry({"scope": "COMPANY", "key": "pis_cofins_profile", "value": "{\"pis_out_cst\": \"49\"}"})[1] is None
|
||||
# Missão 2 / Bloco 2.3: the two audited sentinels must validate (case-insensitive) alongside the JSON profile shape.
|
||||
ok, err = validate_entry({"scope": "COMPANY", "key": "pis_cofins_profile", "value": "not_applicable"})
|
||||
assert err is None and ok["value"] == "NOT_APPLICABLE"
|
||||
ok, err = validate_entry({"scope": "COMPANY", "key": "pis_cofins_profile", "value": "OPERATION_TIME"})
|
||||
assert err is None and ok["value"] == "OPERATION_TIME"
|
||||
|
||||
|
||||
def test_import_is_all_or_nothing_upserting_and_listed():
|
||||
c = client()
|
||||
body = {"company_key": "abc123", "entries": [{"scope": "GROUP", "scope_key": "000019", "key": "product_role", "value": "RESALE", "reason": "revenda"},
|
||||
{"scope": "COMPANY", "key": "entry_scope", "value": "INTRA"}]}
|
||||
assert c.post("/v1/context/import", json=body, headers=H).json() == {"created": 2, "updated": 0}
|
||||
body["entries"][0]["value"] = "OWN_PRODUCTION"
|
||||
assert c.post("/v1/context/import", json=body, headers=H).json() == {"created": 0, "updated": 2}
|
||||
listed = c.get("/v1/context", params={"company_key": "abc123"}, headers=H).json()["entries"]
|
||||
assert {(e["scope"], e["key"], e["value"]) for e in listed} == {("GROUP", "product_role", "OWN_PRODUCTION"), ("COMPANY", "entry_scope", "INTRA")}
|
||||
bad = {"company_key": "abc123", "entries": [{"scope": "COMPANY", "key": "rounding", "value": "A"}, {"scope": "COMPANY", "key": "rounding", "value": "Z"}]}
|
||||
r = c.post("/v1/context/import", json=bad, headers=H)
|
||||
assert r.status_code == 422
|
||||
assert len(c.get("/v1/context", params={"company_key": "abc123"}, headers=H).json()["entries"]) == 2 # nothing partially stored
|
||||
assert c.get("/v1/context", params={"company_key": "other"}, headers=H).json()["entries"] == []
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Contexto Fiscal (Agent detail, ADMIN only): confirm/alter/clear one of the reduced set of real fiscal decisions
|
||||
(icms_treatment/pis_cofins_profile/entry_scope). CSRF, RBAC and audit reuse the exact same mechanism already proven for
|
||||
/agents/{id}/features; persistence reuses FiscalContextEntry, the same table blocker_resolution already writes to."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.models import AuditEvent, FiscalContextEntry, Installation, ProductEnrichment
|
||||
from central.tests.test_control_panel_pages import _panel_client
|
||||
from central.tests.test_rtc_security import _become, _post
|
||||
|
||||
|
||||
def _company_key(client):
|
||||
with client._session_factory() as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
return (inst.company_code or inst.company_name or "").strip(), inst.tenant_id, inst.id
|
||||
|
||||
|
||||
def test_fiscal_context_tab_lists_only_the_three_reduced_decisions_with_evidence():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
page = client.get(f"/agents/{agent_id}").text
|
||||
assert "Contexto Fiscal" in page
|
||||
for title in ("Tratamento padrão de ICMS", "Perfil de PIS/COFINS", "CFOP de entrada padrão"):
|
||||
assert title in page
|
||||
assert "487 de 521" in page # real evidence shown, not a raw internal key name
|
||||
assert "<h3>Tratamento padrão de ICMS dos produtos comuns</h3>" in page # human title heads the card; internal key only appears as a hidden form field
|
||||
|
||||
|
||||
def test_fiscal_context_hidden_from_non_admin():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "PADRAO")
|
||||
page = client.get(f"/agents/{agent_id}").text
|
||||
assert "Contexto Fiscal" not in page
|
||||
|
||||
|
||||
def test_confirming_a_decision_is_csrf_admin_and_audited_and_flags_reevaluation():
|
||||
client, agent_id, _ = _panel_client()
|
||||
company_key, tenant_id, installation_id = _company_key(client)
|
||||
with client._session_factory() as s:
|
||||
s.add(ProductEnrichment(tenant_id=tenant_id, installation_id=installation_id, product_code="P1", status="RESOLVED", ui_status="PRONTO_PARA_PREENCHER"))
|
||||
s.commit()
|
||||
_become(client, "PADRAO")
|
||||
assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 403
|
||||
_become(client, "ADMIN")
|
||||
assert client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": "bad", "key": "icms_treatment", "value": "TAXED", "fc_action": "confirm"}, follow_redirects=False).status_code in (400, 403)
|
||||
assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 303
|
||||
with client._session_factory() as s:
|
||||
entry = s.scalars(select(FiscalContextEntry).where(FiscalContextEntry.key == "icms_treatment")).one()
|
||||
assert entry.scope == "COMPANY" and entry.value == "TAXED" and entry.source == "OPERATOR"
|
||||
acts = [e.action for e in s.scalars(select(AuditEvent))]
|
||||
assert "FISCAL_CONTEXT_DECISION" in acts
|
||||
row = s.scalars(select(ProductEnrichment)).one()
|
||||
assert row.reevaluate is True # existing products get a chance to pick up the new policy - never a silent no-op
|
||||
|
||||
|
||||
def test_clearing_a_decision_deletes_the_entry_never_a_default_value():
|
||||
client, agent_id, _ = _panel_client()
|
||||
company_key, tenant_id, installation_id = _company_key(client)
|
||||
_become(client, "ADMIN")
|
||||
assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 303
|
||||
assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", fc_action="clear").status_code == 303
|
||||
with client._session_factory() as s:
|
||||
assert s.scalars(select(FiscalContextEntry).where(FiscalContextEntry.key == "icms_treatment")).first() is None
|
||||
|
||||
|
||||
def test_invalid_value_is_rejected_never_silently_stored():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
resp = client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": client.cookies.get("gti_csrf"), "key": "icms_treatment", "value": "NOT_A_REAL_TREATMENT", "fc_action": "confirm"}, follow_redirects=False)
|
||||
assert resp.status_code == 422
|
||||
with client._session_factory() as s:
|
||||
assert s.scalars(select(FiscalContextEntry)).first() is None
|
||||
|
||||
|
||||
def test_unknown_key_is_refused():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
resp = client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": client.cookies.get("gti_csrf"), "key": "not_a_real_key", "value": "x", "fc_action": "confirm"}, follow_redirects=False)
|
||||
assert resp.status_code == 422
|
||||
@@ -0,0 +1,71 @@
|
||||
from central.app.fiscal_decision import ERP_FIELD_MAP, decide_product
|
||||
|
||||
REFS = {"cfop_valid": {"5102"}, "csosn": {"102", "500"}, "cst_icms": {"00", "40", "60"}, "cst_pis": {"01"}, "cst_cofins": {"01"},
|
||||
"origem": set("012345678"), "fcp_uf": {"BA": {"aliq_1": "Fixo:2.00"}}, "cbs_rate_2026": 0.009, "cclasstrib_count": 164}
|
||||
COMPANY = {"uf": "BA", "crt": "1"}
|
||||
|
||||
|
||||
def product(**kw):
|
||||
base = {"CODIGO": "1", "PRODUTO": "X", "CLASSIFICACAO_FISCAL": "22030000", "CFOP": "5102", "CST": "040", "CSOSN": "102",
|
||||
"CEST": None, "IPI": None, "EX_TIPI": None, "ORIGEM": None, "COD_SAIDA_PIS": None, "COD_SAIDA_COFINS": None, "ALIQ_PIS": None}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def decision(**kw):
|
||||
base = {"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED", "cest_status": "CEST_CANDIDATES", "ba_st_mva_status": "MVA_CANDIDATES",
|
||||
"dataset_versions": {}, "classification": {"ncm": "22030000", "ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9}, "cest_candidates": [1, 2]}}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_numeric_ipi_change_is_the_only_auto_apply():
|
||||
out = decide_product(product(IPI=5102.0), decision(), COMPANY, REFS)
|
||||
assert out["IPI"]["action_class"] == "AUTO_APPLY_SAFE" and out["IPI"]["write_eligible"] and out["IPI"]["proposed_value"] == 3.9
|
||||
assert [d for d, r in out.items() if r["write_eligible"]] == ["IPI"]
|
||||
|
||||
|
||||
def test_same_ipi_is_already_correct_not_write():
|
||||
out = decide_product(product(IPI=3.9), decision(), COMPANY, REFS)
|
||||
assert out["IPI"]["action_class"] == "ALREADY_CORRECT" and not out["IPI"]["write_eligible"]
|
||||
|
||||
|
||||
def test_non_numeric_and_blank_ipi_never_write():
|
||||
d = decision(classification={"ipi": {"rate_kind": "NON_NUMERIC", "rate_classification": "NT"}})
|
||||
assert not decide_product(product(), d, COMPANY, REFS)["IPI"]["write_eligible"]
|
||||
d = decision(classification={"ipi": {"rate_kind": "BLANK"}})
|
||||
r = decide_product(product(), d, COMPANY, REFS)["IPI"]
|
||||
assert not r["write_eligible"] and r["status"] == "RULE_UNRESOLVED"
|
||||
|
||||
|
||||
def test_operation_dependent_fields_never_resolved_or_written():
|
||||
out = decide_product(product(), decision(), COMPANY, REFS)
|
||||
for dom in ("CFOP", "CSOSN", "CST_ICMS", "PIS_CST", "COFINS_CST", "FCP", "RTC_CCLASSTRIB", "RTC_CBS_RATE"):
|
||||
assert out[dom]["status"] == "OPERATION_CONTEXT_REQUIRED" and not out[dom]["write_eligible"]
|
||||
assert out["CFOP"]["domain_valid"] is True and out["FCP"]["fcp_rate_available"] is True
|
||||
assert out["RTC_CBS_RATE"]["proposed_value"] is None and out["RTC_CBS_RATE"]["reference_rate_2026"] == 0.009
|
||||
|
||||
|
||||
def test_ncm_legacy_is_review_and_never_write():
|
||||
out = decide_product(product(), decision(status="LEGACY_NCM"), COMPANY, REFS)
|
||||
assert out["NCM"]["action_class"] == "REVIEW_REQUIRED" and not any(r["write_eligible"] for r in out.values())
|
||||
|
||||
|
||||
def test_origin_only_candidate_and_unconfirmed_mapping():
|
||||
out = decide_product(product(), decision(), COMPANY, REFS)
|
||||
assert out["ORIGEM"]["status"] == "RULE_CANDIDATE" and not out["ORIGEM"]["write_eligible"]
|
||||
assert {f["ERP_FIELD"] for f in ERP_FIELD_MAP if f["MAPPING"] == "MAPPING_UNCONFIRMED"} >= {"ORIGEM", "CFOP_ENTRADA", "CST_ENTRADA"}
|
||||
|
||||
|
||||
def test_mva_never_written_even_when_resolved():
|
||||
d = decision(ba_st_mva_status="MVA_RESOLVED", classification={"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 1}, "ba_st_mva": {"mva_original": "140%"}})
|
||||
r = decide_product(product(IPI=1), d, COMPANY, REFS)["MVA"]
|
||||
assert r["status"] == "RULE_RESOLVED" and not r["write_eligible"]
|
||||
|
||||
|
||||
def test_single_ex_without_description_evidence_is_candidate_not_write():
|
||||
cls = {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9}, "ipi_candidates": [{"ex": "1", "description": "Chope"}]}
|
||||
out = decide_product(product(PRODUTO="CERVEJA LATA", IPI=3.9), decision(classification=cls), COMPANY, REFS)
|
||||
assert out["EX_TIPI"]["status"] == "RULE_CANDIDATE" and not out["EX_TIPI"]["write_eligible"]
|
||||
out = decide_product(product(PRODUTO="CHOPE ESCURO", IPI=3.9), decision(classification=cls), COMPANY, REFS)
|
||||
assert out["EX_TIPI"]["status"] == "RULE_RESOLVED" and out["EX_TIPI"]["write_eligible"]
|
||||
@@ -0,0 +1,9 @@
|
||||
from central.app.fiscal_determination import FiscalDetermination
|
||||
|
||||
|
||||
def test_returns_unresolved_csosn_and_cfop_without_official_rule():
|
||||
result = FiscalDetermination().determine({"ncm": "08043000", "cest_candidates": []})
|
||||
|
||||
assert result["csosn"] is None
|
||||
assert result["cfop"] is None
|
||||
assert "OFFICIAL_RULE_NOT_IMPLEMENTED" in result["reason_codes"]
|
||||
@@ -0,0 +1,29 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from central.app.main import create_app
|
||||
|
||||
|
||||
def test_health_reports_database_dependency():
|
||||
client = TestClient(create_app(database_probe=lambda: True))
|
||||
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_health_is_unavailable_when_database_is_down():
|
||||
client = TestClient(create_app(database_probe=lambda: False))
|
||||
|
||||
assert client.get("/health").status_code == 503
|
||||
|
||||
|
||||
def test_version_endpoint_reports_release_and_fiscal_versions():
|
||||
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
|
||||
assert body["version"] == "1.3.15" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
|
||||
|
||||
def test_production_refuses_the_dev_token(monkeypatch):
|
||||
import pytest
|
||||
from central.app import main as main_module
|
||||
monkeypatch.setattr(main_module.settings.__class__, "environment", "production", raising=False) if False else None
|
||||
monkeypatch.setattr(main_module, "settings", type("S", (), {"environment": "production", "agent_dev_token": "dev", "log_level": "INFO"})())
|
||||
with pytest.raises(RuntimeError):
|
||||
create_app(database_probe=lambda: True)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Backpressure: at most HEAVY_JOB_CONCURRENCY_LIMIT whole-database jobs run at once across all Agents; the rest queue as PENDING."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import jobs
|
||||
from central.app.models import Agent, Job, Tenant
|
||||
from central.tests.test_control_plane import setup_db
|
||||
|
||||
|
||||
def _agents(session, n):
|
||||
tenant = Tenant(name="Backpressure")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
out = []
|
||||
for i in range(n):
|
||||
agent = Agent(tenant_id=tenant.id, name=f"a{i}", token_hash=f"t{i}".ljust(64, "x"), agent_version="1.2.1")
|
||||
session.add(agent)
|
||||
out.append(agent)
|
||||
session.flush()
|
||||
return out
|
||||
|
||||
|
||||
def _limit(monkeypatch, value):
|
||||
monkeypatch.setattr(jobs, "settings", SimpleNamespace(heavy_job_concurrency_limit=value))
|
||||
|
||||
|
||||
def test_only_the_configured_number_of_full_jobs_run_at_once_and_the_rest_wait(monkeypatch):
|
||||
_limit(monkeypatch, 2)
|
||||
with setup_db()() as session:
|
||||
agents = _agents(session, 4)
|
||||
queued = [jobs.create_job(session, a, "INITIAL_SYNC") for a in agents]
|
||||
claimed = [jobs.claim_next_job(session, a) for a in agents]
|
||||
assert [c is not None for c in claimed] == [True, True, False, False] # 2 running, 2 queued (backpressure)
|
||||
assert [session.get(Job, q.id).status for q in queued] == ["RUNNING", "RUNNING", "PENDING", "PENDING"]
|
||||
jobs.complete_job(session, session.get(Job, queued[0].id), {}) # a slot frees up
|
||||
assert jobs.claim_next_job(session, agents[2]) is not None and jobs.claim_next_job(session, agents[3]) is None
|
||||
|
||||
|
||||
def test_a_light_incremental_job_is_never_held_behind_a_queued_full_job(monkeypatch):
|
||||
_limit(monkeypatch, 1)
|
||||
with setup_db()() as session:
|
||||
busy, waiting = _agents(session, 2)
|
||||
jobs.create_job(session, busy, "REPROCESS_ALL")
|
||||
assert jobs.claim_next_job(session, busy) is not None
|
||||
full = jobs.create_job(session, waiting, "REPROCESS_ALL")
|
||||
light = jobs.create_job(session, waiting, "REPROCESS_PRODUCTS", {"codigos": ["1"]})
|
||||
claimed = jobs.claim_next_job(session, waiting)
|
||||
assert claimed is not None and claimed.id == light.id and session.get(Job, full.id).status == "PENDING"
|
||||
|
||||
|
||||
def test_limit_zero_disables_backpressure_and_a_stale_running_job_does_not_hold_a_slot(monkeypatch):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
_limit(monkeypatch, 0)
|
||||
with setup_db()() as session:
|
||||
agents = _agents(session, 3)
|
||||
for a in agents:
|
||||
jobs.create_job(session, a, "INITIAL_SYNC")
|
||||
assert all(jobs.claim_next_job(session, a) is not None for a in agents)
|
||||
_limit(monkeypatch, 1)
|
||||
with setup_db()() as session:
|
||||
dead, fresh = _agents(session, 2)
|
||||
stale = jobs.create_job(session, dead, "INITIAL_SYNC")
|
||||
session.execute(Job.__table__.update().where(Job.id == stale.id).values(status="RUNNING", started_at=datetime.now(timezone.utc) - timedelta(hours=3)))
|
||||
session.commit()
|
||||
jobs.create_job(session, fresh, "INITIAL_SYNC")
|
||||
assert jobs.claim_next_job(session, fresh) is not None # the dead Agent's 3 h old RUNNING job does not block the queue
|
||||
@@ -0,0 +1,9 @@
|
||||
def test_missing_historical_identity_is_preserved_not_guessed():
|
||||
from central.app.ai.historical_conflicts import INSUFFICIENT_EVIDENCE, reconstruct_historical_conflicts
|
||||
|
||||
report = reconstruct_historical_conflicts({"products": [{"gtin": "x", "fingerprint": "f"}]}, historical_count=73)
|
||||
assert report["historical_conflicts_before"] == 73
|
||||
assert report["historical_conflicts_resolved"] == 0
|
||||
assert report["historical_conflicts_remaining"] == 73
|
||||
assert report["classification_counts"] == {INSUFFICIENT_EVIDENCE: 73}
|
||||
assert all(row["resolution"] == "PRESERVE_CONFLICT" for row in report["conflicts"])
|
||||
@@ -0,0 +1,58 @@
|
||||
from central.app.icms_context import csosn_v2, product_role, st_conflict_class, st_status, withheld_status
|
||||
|
||||
DOM = {"102", "500", "101", "400"}
|
||||
|
||||
|
||||
def test_cest_candidate_never_confirms_st():
|
||||
assert st_status("EXACT_NCM", "MVA_CANDIDATES", "CEST_CANDIDATES", "", None, 4)[0] == "ST_CANDIDATE"
|
||||
assert st_status("EXACT_NCM", "MVA_RESOLVED", "CEST_CANDIDATES", "", "03.021.03", 4)[0] == "ST_CANDIDATE"
|
||||
|
||||
|
||||
def test_st_confirmed_needs_erp_cest_corroboration():
|
||||
assert st_status("EXACT_NCM", "MVA_RESOLVED", "CEST_CANDIDATES", "0302103", "03.021.03", 4)[0] == "ST_CONFIRMED"
|
||||
assert st_status("EXACT_NCM", "MVA_RESOLVED", "CEST_CONFIRMED", "", "03.021.03", 4)[0] == "ST_CONFIRMED"
|
||||
|
||||
|
||||
def test_st_not_applicable_needs_both_lists_silent_and_valid_ncm():
|
||||
assert st_status("EXACT_NCM", "NO_MVA_MAPPING_FOUND", "NO_CEST_MAPPING_FOUND", None, None, None)[0] == "ST_NOT_APPLICABLE"
|
||||
assert st_status("EXACT_NCM", "NO_MVA_MAPPING_FOUND", "CEST_CANDIDATES", None, None, None)[0] == "ST_UNKNOWN"
|
||||
assert st_status("LEGACY_NCM", "NO_MVA_MAPPING_FOUND", "NO_CEST_MAPPING_FOUND", None, None, None)[0] == "ST_UNKNOWN"
|
||||
|
||||
|
||||
def test_withholding_needs_positive_purchase_evidence():
|
||||
assert withheld_status([])[0] == "ST_WITHHOLDING_UNKNOWN"
|
||||
assert withheld_status([{"icms_retido": "N", "valor_retido": 0, "sub_valor": 0}])[0] == "ST_ALREADY_WITHHELD_NOT_CONFIRMED"
|
||||
assert withheld_status([{"icms_retido": "S", "valor_retido": 0, "sub_valor": 0}])[0] == "ST_ALREADY_WITHHELD_CONFIRMED"
|
||||
assert withheld_status([{"icms_retido": "N", "valor_retido": 5, "sub_valor": 0}])[0] == "ST_ALREADY_WITHHELD_CONFIRMED"
|
||||
|
||||
|
||||
def test_role_uses_objective_evidence_only():
|
||||
assert product_role("MATERIA-PRIMA", False, False, False, False, False)[0] == "INPUT"
|
||||
assert product_role("VENDA", True, False, False, False, False)[0] == "OWN_PRODUCTION"
|
||||
assert product_role("VENDA", False, False, False, False, True)[0] == "RESALE"
|
||||
assert product_role("VENDA", False, False, False, False, False)[0] == "UNKNOWN"
|
||||
assert product_role("VENDA", True, False, False, False, True)[0] == "UNKNOWN"
|
||||
|
||||
|
||||
def test_csosn_never_assumes_102_and_never_writes():
|
||||
for st in ("ST_CANDIDATE", "ST_UNKNOWN"):
|
||||
r = csosn_v2("102", "102", "EXACT_NCM", st, "ST_WITHHOLDING_UNKNOWN", "RESALE", DOM)
|
||||
assert r["status"] == "CSOSN_CONTEXT_REQUIRED" and r["proposed_csosn"] is None
|
||||
r = csosn_v2("102", "102", "EXACT_NCM", "ST_NOT_APPLICABLE", "ST_WITHHOLDING_UNKNOWN", "RESALE", DOM)
|
||||
assert r["status"] == "CSOSN_CANDIDATE" and not r["write_eligible"]
|
||||
assert csosn_v2("102", "102", "EXACT_NCM", "ST_NOT_APPLICABLE", "ST_WITHHOLDING_UNKNOWN", "UNKNOWN", DOM)["status"] == "CSOSN_CONTEXT_REQUIRED"
|
||||
assert csosn_v2("102", "102", "LEGACY_NCM", "ST_NOT_APPLICABLE", "ST_WITHHOLDING_UNKNOWN", "RESALE", DOM)["status"] == "CSOSN_UNRESOLVED"
|
||||
|
||||
|
||||
def test_csosn_resolved_only_with_st_withholding_and_role_proven():
|
||||
r = csosn_v2("102", "102", "EXACT_NCM", "ST_CONFIRMED", "ST_ALREADY_WITHHELD_CONFIRMED", "RESALE", DOM)
|
||||
assert r["status"] == "CSOSN_RESOLVED" and r["proposed_csosn"] == "500" and r["write_eligible"] is False
|
||||
assert csosn_v2("102", "102", "EXACT_NCM", "ST_CONFIRMED", "ST_WITHHOLDING_UNKNOWN", "RESALE", DOM)["status"] == "CSOSN_CONTEXT_REQUIRED"
|
||||
assert csosn_v2("500", "102", "EXACT_NCM", "ST_CONFIRMED", "ST_ALREADY_WITHHELD_NOT_CONFIRMED", "RESALE", DOM)["status"] == "CSOSN_CONFLICT"
|
||||
|
||||
|
||||
def test_conflict_classes():
|
||||
assert st_conflict_class("CEST_CANDIDATES", "MVA_RESOLVED", 4, "", "03.021.03", "5102", True, False)[0] == "ERP_HISTORY_CONFLICT"
|
||||
assert st_conflict_class(None, "NO_MVA_MAPPING_FOUND", None, "", None, "5405", False, True)[0] == "BA_ST_RULE_CONFLICT"
|
||||
assert st_conflict_class("CEST_DESCRIPTION_MISMATCH", "MVA_RESOLVED", 6, "", None, "5102", False, False)[0] == "DESCRIPTION_MISMATCH"
|
||||
assert st_conflict_class(None, "MVA_RESOLVED", 6, "", None, "5102", False, False)[0] == "NO_EVIDENCE"
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Install-code enrollment flow: POST /v1/agents/enroll with install_code.
|
||||
|
||||
Covers the universal-EXE enrollment path — the operator types a 16-char
|
||||
XXXX-XXXX-XXXX-XXXX installation code into Setup.exe, the Hub resolves the
|
||||
installation by the hash on its own (no installation_id, no enrollment.json),
|
||||
issues the permanent agent credential, and exhausts the code (replay-proof).
|
||||
Also covers rejection when code and legacy token are sent together, expiry,
|
||||
attempts/rate-limit and no-plaintext persistencen.
|
||||
"""
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config, enrollment, rate_limit, release_storage
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentEnrollment, AgentRelease, AuditEvent, Installation, Tenant
|
||||
from central.scripts.publish_agent_release import publish
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
VERSION = "1.1.1"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
rate_limit._attempts.clear()
|
||||
yield
|
||||
rate_limit._attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path, monkeypatch):
|
||||
replaced = dataclasses.replace(
|
||||
config.settings,
|
||||
agent_release_storage_path=str(tmp_path / "releases"),
|
||||
agent_enrollment_ttl_seconds=86400,
|
||||
base_url="https://gtifiscal.example.gti",
|
||||
web_cookie_secure=False,
|
||||
)
|
||||
monkeypatch.setattr(config, "settings", replaced)
|
||||
if "app.config" in sys.modules:
|
||||
monkeypatch.setattr(sys.modules["app.config"], "settings", replaced)
|
||||
factory = setup_db()
|
||||
seed(factory)
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
return client, factory
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def csrf(client):
|
||||
return client.cookies.get("gti_csrf", "")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release_id(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
data = io_bytes_tmp(tmp_path, VERSION)
|
||||
with factory() as session:
|
||||
release, _created = publish(session, data, version=VERSION)
|
||||
return release.id
|
||||
|
||||
|
||||
def io_bytes_tmp(tmp_path, version):
|
||||
from pathlib import Path
|
||||
buf = io_bytes_bundle(version)
|
||||
path = Path(tmp_path) / f"GTIFiscalAgent-{version}.zip"
|
||||
path.write_bytes(buf)
|
||||
return path
|
||||
|
||||
|
||||
def io_bytes_bundle(version):
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr(f"GTIFiscalAgent-{version}/gti-agent.ps1", "# fake\n")
|
||||
z.writestr(f"GTIFiscalAgent-{version}/VERSION", version)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def tenant_id(factory):
|
||||
with factory() as session:
|
||||
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
# /agents/new only offers active GTI_GERENCIADOR-sourced tenants
|
||||
# (central/app/client_sync.py) — flip the shared fixture tenant so
|
||||
# existing form-based tests keep working under that rule.
|
||||
if tenant.source != "GTI_GERENCIADOR" or not tenant.active:
|
||||
tenant.source, tenant.external_client_id, tenant.active = "GTI_GERENCIADOR", tenant.id, True
|
||||
session.commit()
|
||||
return tenant.id
|
||||
|
||||
|
||||
def make_installation(factory, release_id, name="Servidor E2E"):
|
||||
with factory() as session:
|
||||
enr = AgentEnrollment(enrollment_uuid=str(__import__("uuid").uuid4()), tenant_id=tenant_id(factory),
|
||||
display_name=name, release_id=release_id, status="PENDING")
|
||||
session.add(enr)
|
||||
session.commit()
|
||||
return enr.enrollment_uuid
|
||||
|
||||
|
||||
def set_code(factory, uuid, code, ttl_seconds=86400):
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.install_code_hash = enrollment.install_code_digest(code)
|
||||
enr.install_code_expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
enr.install_code_created_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
|
||||
|
||||
def audits(factory):
|
||||
with factory() as session:
|
||||
return session.scalars(select(AuditEvent)).all()
|
||||
|
||||
|
||||
# ---------- happy path: code alone resolves the installation ----------
|
||||
|
||||
def test_enroll_with_install_code_alone_creates_agent(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
|
||||
enrolled = client.post("/v1/agents/enroll", json={"install_code": code, "agent_version": "1.1.1", "hostname": "WIN-E2E", "platform": "windows"})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
|
||||
assert body["installation_id"] == uuid
|
||||
|
||||
with factory() as session:
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.1.1"
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
assert installation.installation_id == uuid and installation.hostname == "WIN-E2E"
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.status == "ENROLLED" and enr.agent_id == agent.id
|
||||
assert enr.install_code_hash is None and enr.install_code_expires_at is None # code consumed, replay-proof
|
||||
|
||||
|
||||
def test_replay_of_consumed_code_is_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
first = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert first.status_code == 200
|
||||
second = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert second.status_code == 401
|
||||
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert "ENROLLMENT_SUCCEEDED" in events and "ENROLLMENT_REJECTED" in events
|
||||
raw = json.dumps({"action": "ENROLLMENT_SUCCEEDED", "target": ["x"], "context": {"ip": "y"}}) + code
|
||||
# the code plaintext is never in any audit event context
|
||||
for event in audits(factory):
|
||||
assert code not in json.dumps(event.context)
|
||||
assert enrollment.normalize_install_code(code) not in json.dumps(event.context)
|
||||
|
||||
|
||||
def test_code_is_case_and_hyphen_insensitive(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
typed = code.lower().replace("-", " ")
|
||||
enrolled = client.post("/v1/agents/enroll", json={"install_code": typed})
|
||||
assert enrolled.status_code == 200
|
||||
|
||||
|
||||
# ---------- rejections ----------
|
||||
|
||||
def test_invalid_code_format_rejected_without_lookup(hub, release_id):
|
||||
client, factory = hub
|
||||
invalid = client.post("/v1/agents/enroll", json={"install_code": "NOT-A-CODE"})
|
||||
assert invalid.status_code == 400 or invalid.status_code == 401
|
||||
assert "INVALID" in str(invalid.json())
|
||||
|
||||
|
||||
def test_unknown_but_wellformed_code_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
# valid-format code that was never issued for this Hub
|
||||
made_up = "-".join(["0123"] * 4)
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": made_up})
|
||||
assert rejected.status_code == 401
|
||||
assert "INVALID" in str(rejected.json())
|
||||
|
||||
|
||||
def test_expired_code_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code, ttl_seconds=-60)
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert rejected.status_code == 401
|
||||
assert "EXPIRED" in str(rejected.json())
|
||||
|
||||
|
||||
def test_code_for_revoked_installation_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.status = "REVOKED"
|
||||
session.commit()
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert rejected.status_code == 401
|
||||
assert "REVOKED" in str(rejected.json())
|
||||
|
||||
|
||||
def test_code_and_token_together_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
rejected = client.post("/v1/agents/enroll", json={
|
||||
"install_code": code, "installation_id": uuid, "enrollment_token": "x" * 43,
|
||||
})
|
||||
assert rejected.status_code == 400
|
||||
|
||||
|
||||
def test_failed_attempts_increment_and_rate_limit(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
# several wrong-format tries that never reach the code lookup
|
||||
for _ in range(12):
|
||||
client.post("/v1/agents/enroll", json={"install_code": "NOT-A-CODE"})
|
||||
assert rate_limit.blocked("enroll", "testclient")
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.install_code_attempts >= 0 # format failures don't consume this installation's code attempts
|
||||
|
||||
|
||||
def test_wrong_code_rejected_as_invalid(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
right_code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, right_code)
|
||||
wrong = enrollment.generate_install_code() # well-formed but never issued
|
||||
attempt = client.post("/v1/agents/enroll", json={"install_code": wrong})
|
||||
assert attempt.status_code == 401
|
||||
assert "INVALID" in str(attempt.json())
|
||||
|
||||
|
||||
def test_resolved_code_failures_increment_attempt_budget(hub, release_id):
|
||||
# A well-formed code that resolves to a REVOKED installation consumes the
|
||||
# installation's attempt budget (per-installation rate limit), same as an
|
||||
# expired one.
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.status = "REVOKED"
|
||||
session.commit()
|
||||
for _ in range(3):
|
||||
client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.install_code_attempts >= 3
|
||||
|
||||
|
||||
def test_legacy_token_path_still_works(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
token = enrollment.new_token()
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=86400)
|
||||
session.commit()
|
||||
enrolled = client.post("/v1/agents/enroll", json={"installation_id": uuid, "enrollment_token": token})
|
||||
assert enrolled.status_code == 200
|
||||
|
||||
|
||||
# ---------- publish EXE releases (immutable) ----------
|
||||
|
||||
def test_publish_exe_release(hub, tmp_path):
|
||||
client, factory = hub
|
||||
from central.app import release_storage
|
||||
exe_bytes = b"MZ\x90\x00 fake windows installer exe body \x00" * 100
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(exe_bytes)
|
||||
with factory() as session:
|
||||
release, created = publish(session, exe_path, version="1.1.1", notes="universal installer")
|
||||
assert created and release.status == "PUBLISHED" and release.published_at is not None
|
||||
assert release.filename == "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
assert release_storage.safe_path_for(release.storage_path).read_bytes() == exe_bytes
|
||||
|
||||
|
||||
def test_publish_exe_is_immutable(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ fake body v1")
|
||||
with factory() as session:
|
||||
publish(session, exe_path, version="1.1.1")
|
||||
exe_path.write_bytes(b"MZ DIFFERENT body v2")
|
||||
with factory() as session:
|
||||
with pytest.raises(SystemExit):
|
||||
publish(session, exe_path, version="1.1.1") # refused: immutable once PUBLISHED
|
||||
|
||||
|
||||
def test_publish_identical_exe_is_noop(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ same bytes")
|
||||
with factory() as session:
|
||||
release, created = publish(session, exe_path, version="1.1.1")
|
||||
assert created
|
||||
with factory() as session:
|
||||
release2, created2 = publish(session, exe_path, version="1.1.1")
|
||||
assert release2.id == release.id and not created2
|
||||
|
||||
|
||||
# ---------- EXE download flow (panel: code -> download -> enroll) ----------
|
||||
|
||||
def publish_exe_release(factory, tmp_path):
|
||||
exe_path = tmp_path / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ\x90\x00 GTIFiscalAgent installer 1.1.1 \x00" * 40)
|
||||
with factory() as session:
|
||||
release, _created = publish(session, exe_path, version="1.1.1")
|
||||
return release.id
|
||||
|
||||
|
||||
def test_panel_flow_code_generate_download_and_enroll_with_exe(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
|
||||
"display_name": "Servidor EXE", "release_id": release_id}, follow_redirects=False)
|
||||
assert created.status_code == 303
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
|
||||
# generate the install code (only hash stored; plaintext shown once via ?code=)
|
||||
code_page = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
assert code_page.status_code == 200
|
||||
# the redirect carried ?code=XXXX-... ; the page must show it
|
||||
code_addr = code_page.history[0].headers.get("location") if code_page.history else ""
|
||||
assert "code=" in code_addr
|
||||
install_code = code_addr.rsplit("code=", 1)[-1].split("&")[0]
|
||||
assert len(install_code) == 19
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.install_code_hash == enrollment.install_code_digest(install_code)
|
||||
assert enr.install_code_expires_at is not None
|
||||
assert enr.install_code_hash != install_code # never plaintext in DB
|
||||
|
||||
# download the EXE from the pending page (authenticated direct download)
|
||||
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.content.startswith(b"MZ")
|
||||
assert "attachment" in downloaded.headers["content-disposition"]
|
||||
assert "GTIFiscalAgent-Setup-1.1.1.exe" in downloaded.headers["content-disposition"]
|
||||
|
||||
# enroll with just the code (no installation id, no token)
|
||||
anon = TestClient(client.app)
|
||||
enrolled = anon.post("/v1/agents/enroll", json={"install_code": install_code, "agent_version": "1.1.1", "hostname": "WIN-EXE"})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"]
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "ENROLLED" and enr.install_code_hash is None # consumed
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.1.1"
|
||||
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert {"INSTALLATION_CREATED", "INSTALLATION_CODE_CREATED", "INSTALLER_DOWNLOADED", "ENROLLMENT_STARTED",
|
||||
"ENROLLMENT_SUCCEEDED", "AGENT_CREDENTIAL_ISSUED"} <= events
|
||||
for event in audits(factory):
|
||||
assert install_code not in json.dumps(event.context)
|
||||
|
||||
|
||||
def test_rotation_generates_a_code_that_invalidates_the_previous(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
|
||||
"display_name": "Rotação", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
first = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
first_code = first.history[0].headers["location"].rsplit("code=", 1)[-1].split("&")[0]
|
||||
second = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
second_code = second.history[0].headers["location"].rsplit("code=", 1)[-1].split("&")[0]
|
||||
assert first_code != second_code
|
||||
# first code no longer resolves (rotated), second does
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.install_code_hash == enrollment.install_code_digest(second_code)
|
||||
anon = TestClient(client.app)
|
||||
assert anon.post("/v1/agents/enroll", json={"install_code": first_code}).status_code == 401
|
||||
assert anon.post("/v1/agents/enroll", json={"install_code": second_code}).status_code == 200
|
||||
events = [e.action for e in audits(factory)]
|
||||
assert events.count("INSTALLATION_CODE_CREATED") >= 2
|
||||
|
||||
|
||||
def test_revoke_clears_code_and_is_audited(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": tenant_id(factory),
|
||||
"display_name": "Revoga", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)})
|
||||
revoked = client.post(f"/agents/pending/{installation_uuid}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert revoked.status_code == 303
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "REVOKED" and enr.install_code_hash is None
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Installation code (install_code) for the Windows installer EXE flow.
|
||||
|
||||
Covers central/app/enrollment.py install-code functions: Crockford Base32
|
||||
generation, normalization (case + hyphen), hashing, and the grouped display
|
||||
format. Never stores the plaintext code.
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from central.app import enrollment
|
||||
|
||||
|
||||
def test_generated_code_is_16_crockford_chars_in_4_groups():
|
||||
for _ in range(50):
|
||||
code = enrollment.generate_install_code()
|
||||
assert re.fullmatch(r"[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}", code)
|
||||
|
||||
|
||||
def test_generated_code_has_good_entropy():
|
||||
seen = {enrollment.generate_install_code() for _ in range(2000)}
|
||||
assert len(seen) > 1900 # 80 bits of entropy: collisions essentially impossible
|
||||
|
||||
|
||||
def test_crockford_alphabet_excludes_ambiguous_chars():
|
||||
alphabet = set(enrollment.INSTALL_CODE_ALPHABET)
|
||||
for ambiguous in "ILOU":
|
||||
assert ambiguous not in alphabet
|
||||
|
||||
|
||||
def test_normalization_uppercases_and_strips_separators():
|
||||
assert enrollment.normalize_install_code("abcd-efgh-jkmn-pqrz") == "ABCDEFGHJKMNPQRZ"
|
||||
assert enrollment.normalize_install_code(" abcd efgh jkmn pqrz ") == "ABCDEFGHJKMNPQRZ"
|
||||
assert enrollment.normalize_install_code("ABCD-EFGH-JKMN-PQRZ") == "ABCDEFGHJKMNPQRZ"
|
||||
|
||||
|
||||
def test_normalization_maps_crockford_lookalikes():
|
||||
# Crockford Base32 treats 0/O and 1/I/L as transliterations; a human
|
||||
# retyping "O" and "I/L" means "0" and "1".
|
||||
assert enrollment.normalize_install_code("OOOI-LLLL-0000-0001") == "0001111100000001"
|
||||
assert enrollment.normalize_install_code("0O0O-1L1I-0000-0001") == enrollment.normalize_install_code("0000-1111-0000-0001")
|
||||
|
||||
|
||||
def test_digest_is_sha256_hex_and_deterministic():
|
||||
code = enrollment.generate_install_code()
|
||||
normalized = enrollment.normalize_install_code(code)
|
||||
assert enrollment.install_code_digest(code) == enrollment.install_code_digest(normalized)
|
||||
assert enrollment.install_code_digest(code) == enrollment.install_code_digest(code.lower().replace("-", ""))
|
||||
assert len(enrollment.install_code_digest(code)) == 64
|
||||
|
||||
|
||||
def test_hashes_cannot_be_reversed_into_plaintext():
|
||||
code = enrollment.generate_install_code()
|
||||
digest = enrollment.install_code_digest(code)
|
||||
assert digest != code
|
||||
assert code not in digest
|
||||
|
||||
|
||||
def test_valid_install_code():
|
||||
code = enrollment.generate_install_code()
|
||||
assert enrollment.valid_install_code(code)
|
||||
assert enrollment.valid_install_code(code.lower().replace("-", ""))
|
||||
assert not enrollment.valid_install_code("")
|
||||
assert not enrollment.valid_install_code("ABCD-EFGH-JKMN-PQRU") # U excluded from Crockford
|
||||
assert not enrollment.valid_install_code("ABCD")
|
||||
assert not enrollment.valid_install_code("!BCD-EFGH-JKMN-PQRZ")
|
||||
@@ -0,0 +1,214 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.jobs import claim_next_job, complete_job, create_job, fail_job
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, Base, BlockedProduct, Installation, Job, Tenant
|
||||
|
||||
|
||||
def _make_agent(session):
|
||||
tenant = Tenant(name="development")
|
||||
session.add(tenant); session.flush()
|
||||
agent = Agent(tenant_id=tenant.id, name="agent-a", token_hash="hash-a")
|
||||
other = Agent(tenant_id=tenant.id, name="agent-b", token_hash="hash-b")
|
||||
session.add_all([agent, other]); session.commit()
|
||||
return agent, other
|
||||
|
||||
|
||||
def test_claim_is_scoped_to_owning_agent_and_marks_running():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
agent, other = _make_agent(session)
|
||||
job = create_job(session, agent, "REPROCESS_ALL")
|
||||
assert claim_next_job(session, other) is None # other agent never sees it
|
||||
claimed = claim_next_job(session, agent)
|
||||
assert claimed.id == job.id
|
||||
assert claimed.status == "RUNNING"
|
||||
assert claimed.attempts == 1
|
||||
|
||||
|
||||
def test_claim_is_not_reentrant_once_running():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
agent, _ = _make_agent(session)
|
||||
create_job(session, agent, "REPROCESS_ALL")
|
||||
first = claim_next_job(session, agent)
|
||||
second = claim_next_job(session, agent) # repeated polling must not duplicate consumption
|
||||
assert first is not None
|
||||
assert second is None
|
||||
|
||||
|
||||
def test_completed_job_can_never_run_again():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
agent, _ = _make_agent(session)
|
||||
create_job(session, agent, "REPROCESS_ALL")
|
||||
job = claim_next_job(session, agent)
|
||||
assert complete_job(session, job, {"writes": 0}) is True
|
||||
assert session.get(Job, job.id).status == "COMPLETED"
|
||||
assert claim_next_job(session, agent) is None
|
||||
assert complete_job(session, job, {"writes": 1}) is False # already terminal
|
||||
|
||||
|
||||
def test_failed_job_is_terminal_with_sanitized_error():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
agent, _ = _make_agent(session)
|
||||
create_job(session, agent, "REPROCESS_ALL")
|
||||
job = claim_next_job(session, agent)
|
||||
assert fail_job(session, job, "boom " * 1000) is True
|
||||
stored = session.get(Job, job.id)
|
||||
assert stored.status == "FAILED"
|
||||
assert len(stored.error) <= 2000
|
||||
|
||||
|
||||
def _client():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
app = create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token")
|
||||
return TestClient(app), engine
|
||||
|
||||
|
||||
def test_job_api_end_to_end_flow():
|
||||
client, engine = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
heartbeat = client.post("/v1/agents/heartbeat", json={"status": "IDLE", "schema_fingerprint": "abc", "agent_version": "0.1.0"}, headers=headers)
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["enabled"] is True
|
||||
|
||||
# Zero-touch: the first heartbeat makes the Hub queue exactly one INITIAL_SYNC by itself.
|
||||
initial = client.get("/v1/jobs/next", headers=headers).json()
|
||||
assert initial["kind"] == "INITIAL_SYNC"
|
||||
assert client.post(f"/v1/jobs/{initial['job_id']}/complete", json={"result_summary": {}}, headers=headers).json()["status"] == "COMPLETED"
|
||||
|
||||
created = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {"codigos": ["000026"]}}, headers=headers)
|
||||
assert created.status_code == 200
|
||||
job_id = created.json()["job_id"]
|
||||
assert created.json()["status"] == "PENDING"
|
||||
|
||||
claimed = client.get("/v1/jobs/next", headers=headers)
|
||||
assert claimed.json()["job_id"] == job_id
|
||||
assert claimed.json()["status"] == "RUNNING"
|
||||
|
||||
empty = client.get("/v1/jobs/next", headers=headers)
|
||||
assert empty.json() is None # no more pending work; not the same job again
|
||||
|
||||
completed = client.post(f"/v1/jobs/{job_id}/complete", json={"result_summary": {"writes_succeeded": 1}}, headers=headers)
|
||||
assert completed.json()["status"] == "COMPLETED"
|
||||
assert completed.json()["result_summary"]["writes_succeeded"] == 1
|
||||
|
||||
fetched = client.get(f"/v1/jobs/{job_id}", headers=headers)
|
||||
assert fetched.json()["status"] == "COMPLETED"
|
||||
|
||||
|
||||
def test_agent_status_derives_online_offline():
|
||||
client, engine = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
offline = client.get("/v1/agents/me", headers=headers)
|
||||
assert offline.json()["status"] == "OFFLINE" # never heartbeated yet
|
||||
|
||||
client.post("/v1/agents/heartbeat", json={"status": "IDLE"}, headers=headers)
|
||||
online = client.get("/v1/agents/me", headers=headers)
|
||||
body = online.json()
|
||||
assert body["status"] == "ONLINE"
|
||||
assert body["enabled"] is True
|
||||
assert "company_id" in body
|
||||
|
||||
|
||||
def test_agent_identity_endpoint_returns_existing_installation_identity_only():
|
||||
client, _ = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
installation_id = "00000000-0000-4000-8000-000000000222"
|
||||
|
||||
heartbeat = client.post(
|
||||
"/v1/agents/heartbeat",
|
||||
json={
|
||||
"installation_id": installation_id,
|
||||
"hostname": "DESKTOP-B0HL02I",
|
||||
"agent_version": "1.1.1",
|
||||
"status": "IDLE",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert heartbeat.status_code == 200
|
||||
|
||||
response = client.get("/v1/agents/me/identity", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"agent_id": heartbeat.json()["agent_id"],
|
||||
"installation_id": installation_id,
|
||||
"hostname": "DESKTOP-B0HL02I",
|
||||
"version": "1.1.1",
|
||||
"status": "ONLINE",
|
||||
}
|
||||
|
||||
|
||||
def test_agent_identity_endpoint_returns_null_installation_without_creating_one():
|
||||
client, engine = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
response = client.get("/v1/agents/me/identity", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {
|
||||
"agent_id": body["agent_id"],
|
||||
"installation_id": None,
|
||||
"hostname": None,
|
||||
"version": None,
|
||||
"status": "OFFLINE",
|
||||
}
|
||||
assert isinstance(body["agent_id"], int) and body["agent_id"] > 0
|
||||
with Session(engine) as session:
|
||||
assert session.query(Installation).count() == 0
|
||||
|
||||
|
||||
def test_agent_identity_endpoint_requires_agent_authentication():
|
||||
client, _ = _client()
|
||||
|
||||
response = client.get("/v1/agents/me/identity")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_job_creation_rejects_unknown_kind():
|
||||
client, _ = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
response = client.post("/v1/jobs", json={"kind": "DELETE_EVERYTHING"}, headers=headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_reprocess_products_requires_codigos():
|
||||
client, _ = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
response = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {}}, headers=headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_blocker_registry_api_deduplicates_and_resolves():
|
||||
client, engine = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
assert client.post("/v1/agents/heartbeat", json={"status": "IDLE", "installation_id": "00000000-0000-4000-8000-000000000001"}, headers=headers).status_code == 200
|
||||
summary = {"blocked_products": [{"company": "C", "product_code": "P1", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "v1", "evidence_summary": {"source": "ERP"}}]}
|
||||
created = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {"codigos": ["P1"]}}, headers=headers)
|
||||
job_id = created.json()["job_id"]
|
||||
client.get("/v1/jobs/next", headers=headers)
|
||||
client.post(f"/v1/jobs/{job_id}/complete", json={"result_summary": summary}, headers=headers)
|
||||
listed = client.get("/v1/blockers?field=CFOP", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()["blockers"]) == 1
|
||||
with Session(engine) as session:
|
||||
assert session.query(BlockedProduct).one().installation_id is not None
|
||||
blocker_id = listed.json()["blockers"][0]["id"]
|
||||
resolved = client.post(f"/v1/blockers/{blocker_id}/resolve", json={"resolution_source": "TEST"}, headers=headers)
|
||||
assert resolved.json()["resolved"] is True
|
||||
assert client.get("/v1/blockers", headers=headers).json()["blockers"] == []
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Regression for the 2026-09-27 production incident: an ADMIN attested the right NCM on the WRONG (but very similar) KB product — "COCA ZERO LATA 350 ML"
|
||||
instead of plain "COCA LATA 350 ML" — because both appeared as CONFLICT with an overlapping candidate list. The pipeline behaved correctly (no write for
|
||||
the unresolved product); the mistake was purely a fiscal data-entry error. These tests lock in: (1) two locally-created Firebird rows with the SAME
|
||||
description never share KB identity through anything but the KB product itself; (2) attesting one GTIN group never resolves an unrelated, distinct one,
|
||||
even when their aliases and candidate NCMs overlap; (3) EAN_AUSENTE is never a terminal state — it becomes resolvable the moment its OWN GTIN group gets
|
||||
verified; (4) a stale (rejected) attest target does not block a later, correct attest.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.enrichment import history, kb
|
||||
from central.app.enrichment.quota import ProviderGateway
|
||||
from central.app.enrichment.resolver import resolve
|
||||
from central.app.models import KbProduct, ProductEnrichment
|
||||
from central.tests.test_enrichment_history import run as seed_history, staging
|
||||
from central.tests.test_product_enrichment_api import H, setup
|
||||
from central.tests.test_product_enrichment_resolver import env # noqa: F401
|
||||
|
||||
|
||||
def gtin(body12):
|
||||
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body12)))
|
||||
return body12 + str((10 - total % 10) % 10)
|
||||
|
||||
|
||||
REGULAR, ZERO = gtin("789490001000"), gtin("789490070000")
|
||||
|
||||
|
||||
def _seed_two_similar_products(env):
|
||||
seed_history(env, [staging("RINCAO", REGULAR, "19059090", years={2024: 900, 2025: 900}, desc="COCA LATA 350 ML"),
|
||||
staging("UNIAO", REGULAR, "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA LATA 350 ML")])
|
||||
seed_history(env, [staging("RINCAO", ZERO, "19059090", years={2024: 900, 2025: 900}, desc="COCA ZERO LATA 350 ML"),
|
||||
staging("UNIAO", ZERO, "22021000", years={2024: 40, 2025: 30}, desc="REFRIGERANTE COCA COLA ZERO LATA 350 ML")])
|
||||
|
||||
|
||||
def test_attesting_the_wrong_similar_gtin_leaves_the_intended_product_unresolved(env):
|
||||
_seed_two_similar_products(env)
|
||||
cur = history.official_sets(env)
|
||||
kb.attest(env, "0" + ZERO, "22021000", "Admin", lambda v: v in cur) # attest the WRONG one, as happened in production
|
||||
result = resolve(env, ProviderGateway(env, []), gtin_raw="", description="COCA-COLA ORIGINAL LATA 350 ML", current={})
|
||||
assert result["fields"]["CLASSIFICACAO_FISCAL"]["status"] != "AUTO_FILL_SAFE" # still not resolved: attesting a DIFFERENT KB product proves nothing here
|
||||
kb.attest(env, "0" + REGULAR, "22021000", "Admin", lambda v: v in cur) # attest the CORRECT one - same rule, correct target
|
||||
result = resolve(env, ProviderGateway(env, []), gtin_raw="", description="COCA-COLA ORIGINAL LATA 350 ML", current={})
|
||||
assert result["state"] == "EXACT_KB_NAME_VERIFIED" and result["fields"]["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
|
||||
|
||||
|
||||
def test_ean_ausente_is_not_terminal_becomes_resolved_once_its_own_gtin_is_verified(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
from sqlalchemy.orm import Session
|
||||
with Session(engine) as s:
|
||||
_seed_two_similar_products(s)
|
||||
s.commit()
|
||||
body = {"product_code": "P1", "gtin": "", "description": "COCA-COLA ORIGINAL LATA 350 ML", "current": {}}
|
||||
first = client.post("/v1/products/resolve", json=body, headers=H).json()
|
||||
with Session(engine) as s:
|
||||
row = s.scalars(select(ProductEnrichment).where(ProductEnrichment.product_code == "P1")).one()
|
||||
assert row.ui_status == "EAN_AUSENTE"
|
||||
cur = history.official_sets(s)
|
||||
kb.attest(s, "0" + REGULAR, "22021000", "Admin", lambda v: v in cur)
|
||||
from central.app.enrichment.status import mark_for_reevaluation
|
||||
inst_id = row.installation_id
|
||||
mark_for_reevaluation(s, inst_id)
|
||||
s.commit()
|
||||
assert s.scalars(select(ProductEnrichment).where(ProductEnrichment.product_code == "P1")).one().reevaluate is True
|
||||
second = client.post("/v1/products/resolve", json=body, headers=H).json()
|
||||
assert second["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE" # EAN_AUSENTE was never a dead end
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(ProductEnrichment).where(ProductEnrichment.product_code == "P1")).one().ui_status == "PRONTO_PARA_PREENCHER"
|
||||
|
||||
|
||||
def test_two_local_products_with_the_identical_description_both_resolve_from_one_kb_verification(monkeypatch):
|
||||
"""Mirrors production 001437/001439: two DIFFERENT Firebird rows, same description, no EAN. One KB verification resolves BOTH (they resolve
|
||||
independently, by name, not through any shared local-row identity)."""
|
||||
client, engine = setup(monkeypatch)
|
||||
from sqlalchemy.orm import Session
|
||||
with Session(engine) as s:
|
||||
_seed_two_similar_products(s)
|
||||
cur = history.official_sets(s)
|
||||
kb.attest(s, "0" + REGULAR, "22021000", "Admin", lambda v: v in cur)
|
||||
s.commit()
|
||||
for code in ("A1", "A2"):
|
||||
body = {"product_code": code, "gtin": "", "description": "COCA-COLA ORIGINAL LATA 350 ML", "current": {}}
|
||||
r = client.post("/v1/products/resolve", json=body, headers=H).json()
|
||||
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE" and r["fields"]["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
|
||||
with Session(engine) as s:
|
||||
assert s.query(KbProduct).count() == 2 # regular and ZERO stayed two distinct KB identities throughout
|
||||
@@ -0,0 +1,31 @@
|
||||
def test_canonical_identity_excludes_tenant_fiscal_context():
|
||||
from central.app.ai.kb_consolidation import canonical_product_id
|
||||
|
||||
a = canonical_product_id({"gtin": "7891234567895", "ncm": "22011000", "database": "A"})
|
||||
b = canonical_product_id({"gtin": "7891234567895", "ncm": "22019000", "database": "B"})
|
||||
assert a == b == "GTIN:7891234567895"
|
||||
|
||||
|
||||
def test_conflicts_are_conservative():
|
||||
from central.app.ai.kb_consolidation import NCM_CONFLICT, PACKAGING_VARIATION, classify_conflict
|
||||
|
||||
assert classify_conflict([
|
||||
{"gtin": "7891234567895", "ncm": "22011000", "normalized_description": "AGUA MINERAL", "package_quantity": "500"},
|
||||
{"gtin": "7891234567895", "ncm": "22019000", "normalized_description": "AGUA MINERAL", "package_quantity": "500"},
|
||||
]) == NCM_CONFLICT
|
||||
assert classify_conflict([
|
||||
{"gtin": "7891234567895", "ncm": "22011000", "normalized_description": "AGUA MINERAL", "package_quantity": "500"},
|
||||
{"gtin": "7891234567895", "ncm": "22011000", "normalized_description": "AGUA MINERAL", "package_quantity": "1L"},
|
||||
]) == PACKAGING_VARIATION
|
||||
|
||||
|
||||
def test_payload_audit_reports_observable_coverage():
|
||||
from central.app.ai.kb_consolidation import audit_payload
|
||||
|
||||
report = audit_payload({"products": [
|
||||
{"gtin": "7891234567895", "ncm": "22011000", "normalized_description": "AGUA", "observations": [{"database": "A"}]},
|
||||
{"gtin": "7891234567895", "ncm": "22019000", "normalized_description": "AGUA", "observations": [{"database": "B"}]},
|
||||
]}, historical_conflicts=163)
|
||||
assert report["historical_conflicts_reported"] == 163
|
||||
assert report["observable_conflicts_audited"] == 1
|
||||
assert report["unobservable_historical_conflicts"] == 162
|
||||
@@ -0,0 +1,98 @@
|
||||
"""KB search bug fix: the search must be GLOBAL server-side (filter -> count -> order -> paginate), lenient for humans (distinct from the strict
|
||||
fiscal `kb.alias_match`), and find a product regardless of which page it would fall on. Never touches the fiscal auto-fill rule."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.enrichment import kb
|
||||
from central.app.enrichment.observability import kb_search, kb_search_ids
|
||||
from central.tests.test_control_panel_pages import _panel_client
|
||||
from central.tests.test_product_enrichment_resolver import env # noqa: F401
|
||||
from central.tests.test_rtc_security import _become, _post
|
||||
|
||||
|
||||
def seed_product(session, gtin, aliases, ncm=None, client="A"):
|
||||
p = kb.upsert_product(session, gtin, aliases[0])
|
||||
for a in aliases[1:]:
|
||||
kb.add_alias(session, p, a)
|
||||
if ncm:
|
||||
kb.add_evidence(session, p, "ncm", ncm, "x", family=kb.CLIENT_FAMILY, client_label=client, lineage={"docs": 20, "years": {"2024": 20}})
|
||||
session.commit()
|
||||
return p
|
||||
|
||||
|
||||
def gtin(body12):
|
||||
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body12)))
|
||||
return "0" + body12 + str((10 - total % 10) % 10)
|
||||
|
||||
|
||||
def test_search_is_global_not_limited_to_a_rendered_page(env):
|
||||
target = seed_product(env, gtin("789100050001"), ["COCA-COLA ORIGINAL LATA 350 ML"], ncm="22021000")
|
||||
for i in range(30): # unrelated noise: if search were page-limited, the target could fall off the visible slice
|
||||
seed_product(env, gtin(f"78920005{i:04d}"), [f"PRODUTO IRRELEVANTE {i} CAIXA 1 UN"])
|
||||
ids = kb_search_ids(env, "COCA COLA LATA 350") # missing the unit "ML": would fail the strict fiscal alias_match
|
||||
assert target.id in ids
|
||||
|
||||
|
||||
def test_search_finds_product_on_first_middle_and_last_page(env):
|
||||
target = seed_product(env, gtin("789100050002"), ["COCA-COLA ORIGINAL LATA 350 ML"])
|
||||
for i in range(25):
|
||||
seed_product(env, gtin(f"78920006{i:04d}"), [f"COCA-COLA ORIGINAL LATA 350 ML VARIANTE {i}"])
|
||||
for page in (1, 2, 3):
|
||||
res = kb_search(env, "COCA COLA LATA 350", page=page, page_size=10)
|
||||
assert res["total"] == 26 and res["pages"] == 3
|
||||
found_on_last_page = kb_search(env, "COCA COLA LATA 350", page=3, page_size=10)
|
||||
assert len(found_on_last_page["results"]) >= 1
|
||||
|
||||
|
||||
def test_search_by_gtin_exact(env):
|
||||
key = gtin("789100050003")
|
||||
seed_product(env, key, ["COCA-COLA ORIGINAL LATA 350 ML"])
|
||||
body = key[1:]
|
||||
assert kb_search_ids(env, body) and kb_search_ids(env, body)[0] == kb_search_ids(env, key)[0]
|
||||
assert kb_search_ids(env, "0000000000000") == []
|
||||
|
||||
|
||||
def test_search_by_name_with_and_without_hyphen_and_accents(env):
|
||||
p = seed_product(env, gtin("789100050004"), ["ACAI ORIGINAL POLPA 100 G"])
|
||||
for query in ("ACAI POLPA 100", "AÇAÍ POLPA 100", "acai polpa 100"):
|
||||
assert p.id in kb_search_ids(env, query), query
|
||||
|
||||
|
||||
def test_search_zero_results_for_unknown_product(env):
|
||||
seed_product(env, gtin("789100050005"), ["FEIJAO CARIOCA 1 KG"])
|
||||
assert kb_search_ids(env, "PRODUTO QUE NAO EXISTE NA BASE") == []
|
||||
|
||||
|
||||
def test_search_before_pagination_semantics(env):
|
||||
"""Filter runs over the WHOLE table, THEN the result is paginated - not the other way round."""
|
||||
matches = [seed_product(env, gtin(f"78910006{i:04d}"), [f"SABONETE LIQUIDO ERVA DOCE {i} 200 ML"]) for i in range(15)]
|
||||
for i in range(40):
|
||||
seed_product(env, gtin(f"78910007{i:04d}"), [f"OUTRO PRODUTO {i} QUALQUER"])
|
||||
res = kb_search(env, "SABONETE LIQUIDO ERVA DOCE", page=2, page_size=10)
|
||||
assert res["total"] == 15 and res["page"] == 2 and len(res["results"]) == 5
|
||||
found_ids = {r["gtin_key"] for r in kb_search(env, "SABONETE LIQUIDO ERVA DOCE", page=1, page_size=10)["results"]} | {r["gtin_key"] for r in res["results"]}
|
||||
assert found_ids == {p.gtin for p in matches}
|
||||
|
||||
|
||||
def test_conflict_candidates_all_visible_and_none_auto_confirmed(env):
|
||||
seed_product(env, gtin("789100050006"), ["COCA COLA LATA 350 ML"], ncm="22030000", client="RINCAO")
|
||||
p2 = seed_product(env, gtin("789100050006"), ["REFRIGERANTE COCA COLA LATA 350 ML"], ncm="22021000", client="UNIAO")
|
||||
# both aliases resolve to the SAME kb product (same GTIN key), so seeding into it twice just adds evidence
|
||||
ids = kb_search_ids(env, "COCA COLA LATA 350")
|
||||
assert p2.id in ids
|
||||
|
||||
|
||||
def test_admin_attest_button_authorization_end_to_end():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as s:
|
||||
from central.tests.test_enrichment_history import staging
|
||||
from central.tests.test_enrichment_history import run as seed_history
|
||||
from central.tests.test_product_enrichment_resolver import seed as seed_ncm_dataset
|
||||
seed_ncm_dataset(s)
|
||||
seed_history(s, [staging("RINCAO", "7891000500071", "19059090", years={2024: 900, 2025: 900}, desc="COCA LATA 350 ML"),
|
||||
staging("UNIAO", "7891000500071", "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA LATA 350 ML")])
|
||||
_become(client, "PADRAO")
|
||||
page = client.get("/?kb_q=COCA COLA LATA 350").text
|
||||
assert "Confirmar este NCM" not in page # button hidden for non-admin
|
||||
_become(client, "ADMIN")
|
||||
page = client.get("/?kb_q=COCA COLA LATA 350").text
|
||||
assert "Confirmar este NCM" in page and "22021000" in page and "19059090" in page
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Real LLM provider contract: strict answers, batching safety, cache, metrics attribution."""
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import Base, AiEvalCache
|
||||
|
||||
|
||||
def _item(work_item_id="w1", candidates=None):
|
||||
from central.app.ai.llm_provider import LlmEvalItem
|
||||
return LlmEvalItem(work_item_id=work_item_id, description="AGUA MINERAL 500ML", normalized_description="AGUA MINERAL 500ML",
|
||||
brand=None, unit="ML", segment="mercado", candidates=candidates or [("22011000", "AGUAS MINERAIS", 0.9), ("22019000", "AGUAS OUTRAS", 0.4)])
|
||||
|
||||
|
||||
def _answer(work_item_id="w1", ncm="22011000", confidence=0.9, abstain=False):
|
||||
return {"work_item_id": work_item_id, "selected_ncm": ncm, "confidence": confidence, "abstain": abstain, "reason_short": "desc match"}
|
||||
|
||||
|
||||
def test_validate_answer_accepts_only_candidate_or_abstain():
|
||||
from central.app.ai.llm_provider import LlmAnswerError, validate_answer
|
||||
item = _item()
|
||||
assert validate_answer(_answer(), item)["selected_ncm"] == "22011000"
|
||||
abstain = validate_answer(_answer(ncm=None, abstain=True), item)
|
||||
assert abstain["abstain"] is True and abstain["selected_ncm"] is None
|
||||
for bad in (_answer(ncm="99999999"), _answer(work_item_id="other"), _answer(ncm=None), _answer(confidence=1.5), _answer(ncm="22011000", abstain=True)):
|
||||
try:
|
||||
validate_answer(bad, item)
|
||||
raise AssertionError("must reject")
|
||||
except LlmAnswerError:
|
||||
pass
|
||||
|
||||
|
||||
def test_provider_batch_requires_1_to_1_answers():
|
||||
from central.app.ai.llm_provider import LlmAnswerError, OpenAICompatibleProvider
|
||||
|
||||
def transport(payload):
|
||||
content = json.dumps([_answer(), _answer()]) # two answers for one item
|
||||
return {"choices": [{"message": {"content": content}}], "usage": {"prompt_tokens": 10, "completion_tokens": 5}}
|
||||
|
||||
provider = OpenAICompatibleProvider(api_key="test-key", model="m", transport=transport)
|
||||
try:
|
||||
provider.select_batch([_item()])
|
||||
raise AssertionError("must reject")
|
||||
except LlmAnswerError:
|
||||
pass
|
||||
assert provider.http_calls == 1 # contract violations are NOT retried
|
||||
|
||||
|
||||
def test_provider_retries_transport_failures_and_counts_usage():
|
||||
from central.app.ai.llm_provider import OpenAICompatibleProvider
|
||||
calls = {"n": 0}
|
||||
|
||||
def transport(payload):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("boom")
|
||||
content = json.dumps([_answer()])
|
||||
return {"choices": [{"message": {"content": content}}], "usage": {"prompt_tokens": 100, "completion_tokens": 20, "prompt_tokens_details": {"cached_tokens": 50}}}
|
||||
|
||||
provider = OpenAICompatibleProvider(api_key="k", model="m", transport=transport)
|
||||
answers = provider.select_batch([_item()])
|
||||
assert answers[0]["selected_ncm"] == "22011000"
|
||||
assert provider.http_calls == 2 and provider.retries == 1
|
||||
assert provider.usage == {"input_tokens": 100, "output_tokens": 20, "cached_tokens": 50}
|
||||
|
||||
|
||||
def test_prompt_hides_gold_and_carries_candidates():
|
||||
from central.app.ai.llm_provider import candidate_set_hash, input_fingerprint, item_prompt_payload
|
||||
item = _item()
|
||||
payload = item_prompt_payload(item)
|
||||
assert "22011000" in json.dumps(payload) # candidates present
|
||||
assert item.normalized_description in json.dumps(payload)
|
||||
assert len(candidate_set_hash(item)) == 64 and len(input_fingerprint(item)) == 64
|
||||
changed = _item(candidates=[("11111111", "X", 0.1)])
|
||||
assert candidate_set_hash(changed) != candidate_set_hash(item)
|
||||
|
||||
|
||||
def test_eval_cache_roundtrip_deduplicates():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
row = AiEvalCache(input_fingerprint="f", candidate_set_hash="c", prompt_version="p", model="m", dataset_version_ncm="d", phase="gold", answer={"selected_ncm": "22011000"}, gold_ncm="22011000")
|
||||
session.add(row); session.commit()
|
||||
existing = session.scalar(select(AiEvalCache).where(AiEvalCache.input_fingerprint == "f", AiEvalCache.candidate_set_hash == "c", AiEvalCache.prompt_version == "p", AiEvalCache.model == "m", AiEvalCache.dataset_version_ncm == "d"))
|
||||
assert existing.answer["selected_ncm"] == "22011000"
|
||||
|
||||
|
||||
def test_metrics_and_error_attribution():
|
||||
from central.app.ai.eval_metrics import attribution, confidence_buckets, metrics
|
||||
rows = [
|
||||
{"gold_in_candidates": True, "selected": "A", "gold": "A", "confidence": 0.99, "invalid": False},
|
||||
{"gold_in_candidates": True, "selected": "B", "gold": "A", "confidence": 0.97, "invalid": False},
|
||||
{"gold_in_candidates": True, "selected": None, "gold": "A", "confidence": 0.5, "invalid": False},
|
||||
{"gold_in_candidates": False, "selected": "B", "gold": "A", "confidence": 0.9, "invalid": False},
|
||||
{"gold_in_candidates": True, "selected": "zzz", "gold": "A", "confidence": 0.9, "invalid": True},
|
||||
]
|
||||
m = metrics(rows)
|
||||
assert m["TOTAL"] == 5 and m["CORRECT"] == 1 and m["WRONG"] == 2 and m["ABSTAIN"] == 1 and m["INVALID"] == 1
|
||||
assert m["GOLD_IN_CANDIDATES"] == 4
|
||||
assert abs(m["SELECTION_ACCURACY_WHEN_GOLD_AVAILABLE"] - 1 / 3) < 1e-4 # correct / (correct+wrong) among valid answers
|
||||
assert abs(m["ABSTENTION_RATE"] - 0.2) < 1e-9
|
||||
a = attribution(rows)
|
||||
assert a["GOLD_NOT_IN_CANDIDATES"] == 1 and a["GOLD_IN_CANDIDATES_BUT_WRONG"] == 1 and a["GOLD_IN_CANDIDATES_BUT_ABSTAIN"] == 1
|
||||
buckets = confidence_buckets(rows)
|
||||
assert buckets[">=0.99"] == {"total": 1, "correct": 1} and buckets["0.95-0.9899"] == {"total": 1, "correct": 0}
|
||||
|
||||
|
||||
def test_zai_model_selection_rules():
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("eval_runner", "central/scripts/run_real_llm_ncm_eval.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
available = ["glm-5.3", "glm-5.3-flash", "glm-4.7", "other-vendor-x"]
|
||||
assert module._select_model(available, "glm-5.3") == ("glm-5.3", "requested")
|
||||
# requested missing -> current GLM flash fallback, never another vendor, never invented
|
||||
assert module._select_model(available, "glm-9.9") == ("glm-5.3-flash", "fallback_glm_flash")
|
||||
only_generic = ["glm-4", "glm-4.7"]
|
||||
assert module._select_model(only_generic, "glm-9.9") == ("glm-4.7", "fallback_glm_latest")
|
||||
assert module._select_model(["gpt-x", "claude-y"], "glm-5.3") == (None, "no_glm_available")
|
||||
assert module._diagnose_http(401) == "credential invalid (AI_API_KEY)"
|
||||
|
||||
|
||||
def test_budget_guard_unknown_cost_limited_run():
|
||||
from central.app.ai.provider import BudgetGuard
|
||||
guard = BudgetGuard(max_run_cost_usd=5.0, price_in_per_m=None, price_out_per_m=None)
|
||||
plan = guard.estimate(1000, 100)
|
||||
assert plan["estimated_cost"] is None and plan["cost_unknown"] is True
|
||||
assert guard.authorize(plan) is False # residual runs refused without prices
|
||||
assert guard.authorize(plan, allow_unknown_cost=True) is True # explicitly limited GOLD benchmark may opt in
|
||||
@@ -0,0 +1,38 @@
|
||||
from central.app.field_contract import ALREADY, CHANGE, MANUAL, NA, sped_pis_cofins_match
|
||||
from central.tests.test_field_contract import CO1, CO3, EV, decision, product, run, st
|
||||
|
||||
UNUSED = {**CO1, "pis_cofins_unused_by_emission": True}
|
||||
|
||||
|
||||
def test_sped_table_lookup_is_prefix_based_and_active_only():
|
||||
hits = sped_pis_cofins_match("10063021")
|
||||
assert hits and {h[1] for h in hits} == {"06"} and hits[0][0] == "4.3.13"
|
||||
assert sped_pis_cofins_match("99999999") == []
|
||||
|
||||
|
||||
def test_simples_pis_cofins_written_only_from_official_table_and_never_assumed_zero():
|
||||
r = run(p=product(CLASSIFICACAO_FISCAL="10063021"), co=UNUSED)
|
||||
assert st(r, "COD_SAIDA_PIS") == (CHANGE, "06") and st(r, "COD_SAIDA_COFINS") == (CHANGE, "06")
|
||||
assert r["ALIQ_PIS"]["state"] == NA and r["COD_ENTRADA_PIS"]["state"] == NA
|
||||
plain = run(co=UNUSED) # NCM without any SPED special regime: SGV does not consume the field, nothing is invented
|
||||
assert all(plain[f]["state"] == NA and plain[f]["proposed"] is None for f in ("COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS"))
|
||||
assert run(co=CO1)["COD_SAIDA_PIS"]["state"] == MANUAL # without emission evidence the company question remains
|
||||
assert run(co={**CO3, "pis_cofins_unused_by_emission": True})["COD_SAIDA_PIS"]["state"] == MANUAL # evidence only relaxes CRT 1
|
||||
|
||||
|
||||
def test_cfop_never_contradicts_the_erp_st_declaration():
|
||||
r = run(p=product(CFOP="5405", CSOSN="500", CST="060", SITUACAO_TRIBUTARIA="T"))
|
||||
assert r["CFOP"]["state"] != CHANGE or r["CFOP"]["proposed"] != "5102"
|
||||
|
||||
|
||||
def test_kitchen_flag_alone_and_cst_digit_never_prove_role_or_origin():
|
||||
r = run(p=product(PROD_COZINHA=1, COD_ORIGEM_MERCADORIA=None, CST="040"))
|
||||
assert r["COD_ORIGEM_MERCADORIA"]["state"] == MANUAL and r["COD_ORIGEM_MERCADORIA"]["reason"] == "ORIGIN_NO_EVIDENCE"
|
||||
assert r["IPPT"]["proposed"] != "P" or r["IPPT"]["state"] != CHANGE
|
||||
|
||||
|
||||
def test_store_ipi_profile_is_a_company_decision_never_defaulted():
|
||||
undecided = run(ipi_profile=None)
|
||||
assert undecided["IPI"]["state"] == MANUAL and undecided["IPI"]["input_key"] == "store_ipi_profile"
|
||||
assert run(ipi_profile={"scope": "COMPANY", "scope_key": "", "key": "store_ipi_profile", "value": "LEAVE_NOT_APPLICABLE"})["IPI"]["state"] == NA
|
||||
assert run()["IPI"]["state"] == CHANGE # POPULATE_REFERENCE_RATE keeps the TIPI rule
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Guards for the migration chain being runnable from an empty database.
|
||||
|
||||
0001 used to build the schema from the *current* ORM models, so every later
|
||||
migration collided with it on a fresh database. These checks run offline (no
|
||||
PostgreSQL) and inspect code, not comments, so they fail if that pattern
|
||||
comes back.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
VERSIONS = ROOT / "central" / "alembic" / "versions"
|
||||
|
||||
|
||||
def _migrations():
|
||||
return {path.name: ast.parse(path.read_text(encoding="utf-8")) for path in sorted(VERSIONS.glob("*.py"))}
|
||||
|
||||
|
||||
def _script_directory():
|
||||
config = Config(str(ROOT / "central" / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(ROOT / "central" / "alembic"))
|
||||
return ScriptDirectory.from_config(config)
|
||||
|
||||
|
||||
def test_no_migration_builds_schema_from_current_models():
|
||||
offenders = []
|
||||
for name, tree in _migrations().items():
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute) and node.attr in ("create_all", "drop_all"):
|
||||
offenders.append((name, node.attr))
|
||||
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("central.app"):
|
||||
offenders.append((name, node.module))
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_chain_is_linear_with_a_single_head():
|
||||
script = _script_directory()
|
||||
assert len(script.get_heads()) == 1
|
||||
revisions = list(script.walk_revisions())
|
||||
assert revisions[-1].revision == "0001"
|
||||
assert revisions[-1].down_revision is None
|
||||
|
||||
|
||||
def test_timestamp_defaults_are_sql_functions_not_string_literals():
|
||||
# A plain string such as "now()" is rendered as a quoted literal that
|
||||
# PostgreSQL freezes at column creation time. 0006 did that and 0019
|
||||
# repairs it; no other migration may repeat it.
|
||||
offenders = []
|
||||
for name, tree in _migrations().items():
|
||||
if name.startswith("0006_"):
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.keyword)
|
||||
and node.arg == "server_default"
|
||||
and isinstance(node.value, ast.Constant)
|
||||
and re.fullmatch(r"\s*(now\s*\(\s*\)|now|current_timestamp|localtimestamp)\s*", str(node.value.value), re.IGNORECASE)
|
||||
):
|
||||
offenders.append(name)
|
||||
assert offenders == []
|
||||
@@ -0,0 +1,15 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_alembic_revision_ids_fit_version_num_column():
|
||||
versions = Path(__file__).parents[1] / "alembic" / "versions"
|
||||
too_long = []
|
||||
for path in versions.glob("*.py"):
|
||||
spec = importlib.util.spec_from_file_location(f"migration_{path.stem}", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
revision = getattr(module, "revision", None)
|
||||
if revision is not None and len(revision) > 32:
|
||||
too_long.append((path.name, revision, len(revision)))
|
||||
assert too_long == []
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.datasets.ncm import parse_ncm_payload
|
||||
|
||||
|
||||
PAYLOAD = b'{"Data_Ultima_Atualizacao_NCM":"2026-09-17","Ato":"Ato","Nomenclaturas":[{"Codigo":"08043000","Descricao":"Abacaxis frescos","Data_Inicio":"2022-01-01","Data_Fim":"9999-12-31"}]}'
|
||||
|
||||
|
||||
def test_import_promotes_and_is_idempotent():
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base, Dataset, NcmEntry
|
||||
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
first = service.import_payload(parse_ncm_payload(PAYLOAD), "https://source", datetime.now(timezone.utc), minimum_entries=1)
|
||||
second = service.import_payload(parse_ncm_payload(PAYLOAD), "https://source", datetime.now(timezone.utc), minimum_entries=1)
|
||||
assert first.id == second.id
|
||||
assert session.get(Dataset, first.id).status == "ACTIVE"
|
||||
assert session.query(NcmEntry).filter_by(dataset_id=first.id).count() == 1
|
||||
|
||||
|
||||
def test_classifier_validates_ncm_and_never_proposes_fiscal_fields():
|
||||
from central.app.classification import Classifier
|
||||
|
||||
class Repository:
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "08043000", "description": "Abacaxis frescos"})()]
|
||||
|
||||
def dataset_versions(self):
|
||||
return {"ncm": "dataset-1"}
|
||||
|
||||
result = Classifier(Repository()).classify({"description": "abacaxi", "current_ncm": "0804.30.00"})
|
||||
assert result["status"] == "EXACT_NCM"
|
||||
assert result["classification"]["ncm"] == "08043000"
|
||||
assert result["classification"]["cest"] is None
|
||||
assert result["can_auto_apply"] is False
|
||||
|
||||
|
||||
def test_api_auth_and_cache():
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Base, Dataset, NcmEntry
|
||||
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
dataset = Dataset(kind="NCM", provider="Siscomex", sha256="a" * 64, version_label="v1", act="act", source_url="url", status="ACTIVE", record_count=1)
|
||||
session.add(dataset)
|
||||
session.flush()
|
||||
session.add(NcmEntry(dataset_id=dataset.id, code="08043000", description="Abacaxis frescos", valid_from="2022", valid_to="9999"))
|
||||
session.commit()
|
||||
app = create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token")
|
||||
client = TestClient(app)
|
||||
payload = {"product": {"product_id": "1", "description": "abacaxi", "current_ncm": "08043000"}}
|
||||
assert client.post("/v1/classify", json=payload).status_code == 401
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
first = client.post("/v1/classify", json=payload, headers=headers).json()
|
||||
second = client.post("/v1/classify", json=payload, headers=headers).json()
|
||||
assert first["request_fingerprint"] == second["request_fingerprint"]
|
||||
assert second["provenance"]["cache_hit"] is True
|
||||
@@ -0,0 +1,172 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
CEST_HTML = b"""
|
||||
<html><body>
|
||||
<table><tr><td>Ignored</td></tr></table>
|
||||
<h2>ANEXO II</h2>
|
||||
<table>
|
||||
<tr><th>ITEM</th><th>CEST</th><th>NCM/SH</th><th>DESCRICAO</th></tr>
|
||||
<tr><td>1.0</td><td>01.001.00</td><td>0804.30.00</td><td>Abacaxis</td></tr>
|
||||
<tr><td>2.0</td><td>01.002.00</td><td>0804.30.00<br>0207.14.00</td><td>Frutas e aves</td></tr>
|
||||
</table>
|
||||
<h2>ANEXO XXVII</h2>
|
||||
<table><tr><th>CEST</th><th>NCM</th></tr><tr><td>99.999.99</td><td>9999.99.99</td></tr></table>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def test_cest_parser_accepts_only_covered_annex_tables_and_hashes_raw_html():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
parsed = parse_cest_html(CEST_HTML)
|
||||
|
||||
assert parsed.version == "CV142_18"
|
||||
assert parsed.sha256
|
||||
assert parsed.entries[0].annex == "II"
|
||||
assert parsed.entries[1].ncm_codes == ("08043000", "02071400")
|
||||
assert len(parsed.entries) == 2
|
||||
|
||||
|
||||
def test_cest_parser_fails_closed_when_an_annex_table_has_no_required_columns():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
with pytest.raises(ValueError, match="required columns"):
|
||||
parse_cest_html(b"<h2>ANEXO II</h2><table><tr><th>CEST</th></tr><tr><td>01.001.00</td></tr></table>")
|
||||
|
||||
|
||||
def test_cest_parser_accepts_official_four_digit_ncm_headings():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
parsed = parse_cest_html(b"<h2>ANEXO III</h2><table><tr><th>CEST</th><th>NCM/SH</th></tr><tr><td>02.001.00</td><td>2205</td></tr></table>")
|
||||
|
||||
assert parsed.entries[0].ncm_codes == ("2205",)
|
||||
|
||||
|
||||
def test_cest_parser_ignores_official_editorial_rows_without_cest_data():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
parsed = parse_cest_html(b"<h2>ANEXO II</h2><table><tr><th>CEST</th><th>NCM/SH</th></tr><tr><td>Nova redacao dada ao item.</td></tr><tr><td>01.001.00</td><td>3815.12.10</td></tr></table>")
|
||||
|
||||
assert [entry.code for entry in parsed.entries] == ["01.001.00"]
|
||||
|
||||
|
||||
def test_cest_parser_preserves_chapter_ncm_prefixes_and_empty_official_ncm_cells():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
parsed = parse_cest_html(b"<h2>ANEXO XXVI</h2><table><tr><th>CEST</th><th>NCM/SH</th></tr><tr><td>28.055.00</td><td>Capitulo 33</td></tr><tr><td>28.056.00</td><td></td></tr></table>")
|
||||
|
||||
assert parsed.entries[0].ncm_codes == ("33",)
|
||||
assert parsed.entries[1].ncm_codes == ()
|
||||
|
||||
|
||||
def test_classifier_distinguishes_exact_legacy_and_invalid_ncm_and_uses_cest_matches():
|
||||
from central.app.classification import Classifier
|
||||
|
||||
class Repository:
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "08043000", "description": "Abacaxis"})()]
|
||||
|
||||
def cest_for_ncm(self, code):
|
||||
return [type("Cest", (), {"code": "01.001.00", "description": "Abacaxis"})()] if code == "08043000" else []
|
||||
|
||||
def legacy_ncm(self, code):
|
||||
return {"code": code, "source": "Siscomex", "source_date": "2026-09-17", "reason": "NCM not present in current official nomenclature; no official successor."} if code == "02071400" else None
|
||||
|
||||
def dataset_versions(self):
|
||||
return {"ncm": "ncm-version", "cest": "cest-version"}
|
||||
|
||||
classifier = Classifier(Repository())
|
||||
|
||||
exact = classifier.classify({"current_ncm": "0804.30.00", "description": "abacaxi"})
|
||||
legacy = classifier.classify({"current_ncm": "0207.14.00"})
|
||||
invalid = classifier.classify({"current_ncm": "0000.00.00"})
|
||||
|
||||
assert exact["status"] == "EXACT_NCM"
|
||||
assert exact["classification"]["cest"] == ["01.001.00"]
|
||||
assert legacy["status"] == "LEGACY_NCM"
|
||||
assert legacy["classification"]["ncm"] == "02071400"
|
||||
assert legacy["legacy_ncm"]["reason"].endswith("no official successor.")
|
||||
assert invalid["status"] == "INVALID_NCM"
|
||||
|
||||
|
||||
def test_classifier_reports_confirmed_cest_only_for_one_distinct_official_candidate():
|
||||
from central.app.classification import Classifier
|
||||
|
||||
class Repository:
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "08043000", "description": "Abacaxi"})()]
|
||||
def cest_for_ncm(self, code):
|
||||
return [type("Cest", (), {"code": "01.001.00", "description": "Abacaxi"})()]
|
||||
def legacy_ncm(self, code): return None
|
||||
def dataset_versions(self): return {"ncm": "n", "cest": "c"}
|
||||
|
||||
result = Classifier(Repository()).classify({"current_ncm": "08043000", "description": "Abacaxi"})
|
||||
assert result["cest_status"] == "CEST_CONFIRMED"
|
||||
|
||||
|
||||
def test_classifier_reports_candidates_for_multiple_distinct_cest_codes():
|
||||
from central.app.classification import Classifier
|
||||
|
||||
class Repository:
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "08043000", "description": "Abacaxi"})()]
|
||||
def cest_for_ncm(self, code):
|
||||
return [type("Cest", (), {"code": "01.001.00", "description": "Abacaxi"})(), type("Cest", (), {"code": "01.002.00", "description": "Frutas"})()]
|
||||
def legacy_ncm(self, code): return None
|
||||
def dataset_versions(self): return {"ncm": "n", "cest": "c"}
|
||||
|
||||
result = Classifier(Repository()).classify({"current_ncm": "08043000", "description": "Abacaxi"})
|
||||
assert result["cest_status"] == "CEST_CANDIDATES"
|
||||
assert result["classification"]["cest"] == ["01.001.00", "01.002.00"]
|
||||
|
||||
|
||||
def test_cest_import_creates_many_to_many_ncm_links_and_stores_html_metadata():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base, CestEntry, CestNcmEntry, Dataset
|
||||
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
dataset = DatasetService(session).import_cest_payload(
|
||||
parse_cest_html(CEST_HTML), "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18", datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert dataset.kind == "CEST"
|
||||
assert dataset.raw_sha256 == dataset.sha256
|
||||
assert session.query(CestEntry).filter_by(dataset_id=dataset.id).count() == 2
|
||||
assert session.query(CestNcmEntry).count() == 3
|
||||
|
||||
|
||||
def test_cest_lookup_matches_official_ncm_prefix_links():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base
|
||||
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
service.import_cest_payload(parse_cest_html(b"<h2>ANEXO III</h2><table><tr><th>CEST</th><th>NCM</th></tr><tr><td>02.001.00</td><td>2205</td></tr></table>"), "https://source", datetime.now(timezone.utc))
|
||||
|
||||
assert [entry.code for entry in service.cest_for_ncm("22051000")] == ["02.001.00"]
|
||||
|
||||
|
||||
def test_cest_import_unions_repeated_official_cest_codes_into_one_link_set():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base, CestEntry, CestNcmEntry
|
||||
|
||||
payload = parse_cest_html(b"<h2>ANEXO II</h2><table><tr><th>CEST</th><th>NCM</th></tr><tr><td>01.042.00</td><td>8421.32.00</td></tr><tr><td>01.042.00</td><td>8421.39.20</td></tr></table>")
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
dataset = DatasetService(session).import_cest_payload(payload, "https://source", datetime.now(timezone.utc))
|
||||
|
||||
assert session.query(CestEntry).filter_by(dataset_id=dataset.id).count() == 1
|
||||
assert {link.ncm_code for link in session.query(CestNcmEntry).all()} == {"84213200", "84213920"}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Missão 6 — Bloco A: Testes de Higiene (Migrations e Inventário dos 9 Datasets)."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_alembic_master_head_is_single():
|
||||
"""Garante que na branch master o alembic possui uma única head: 0035_cfop_ippt_write_mode."""
|
||||
cfg = Config(str(ROOT / "central" / "alembic.ini"))
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
heads = script.get_heads()
|
||||
assert heads == ["0035_cfop_ippt_write_mode"], f"Esperado head única 0035, obtido: {heads}"
|
||||
|
||||
|
||||
def test_feature_branch_migration_chains_to_0034():
|
||||
"""Garante que na branch feature/cfop-ippt-write a migração 0035 encadeia exatamente em 0034."""
|
||||
# Obter conteúdo de 0035 na branch feature/cfop-ippt-write via git show
|
||||
result = subprocess.run(
|
||||
["git", "show", "feature/cfop-ippt-write:central/alembic/versions/0035_cfop_ippt_write_mode.py"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
assert result.returncode == 0, f"Arquivo 0035_cfop_ippt_write_mode.py deve existir na branch feature/cfop-ippt-write: {result.stderr}"
|
||||
content = result.stdout
|
||||
assert 'revision = "0035_cfop_ippt_write_mode"' in content
|
||||
assert 'down_revision = "0034_reference_data_registry"' in content
|
||||
|
||||
# Verifica que 0033 não existe mais na branch
|
||||
res_old = subprocess.run(
|
||||
["git", "show", "feature/cfop-ippt-write:central/alembic/versions/0033_cfop_ippt_write_mode.py"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
assert res_old.returncode != 0, "Migração 0033 não deve mais existir na branch feature"
|
||||
|
||||
|
||||
def test_inventory_9_datasets_coverage():
|
||||
"""Garante que os 9 datasets existentes no Hub estão integralmente catalogados."""
|
||||
from central.app.datasets.inventory import DATASET_INVENTORY_9
|
||||
kinds = [item.kind for item in DATASET_INVENTORY_9]
|
||||
expected_kinds = ["NCM", "TIPI_IPI", "CEST", "BA_ST_MVA", "NFE_MOC_TABLES", "CFOP", "FCP", "ANP", "NCM_UTRIB"]
|
||||
assert kinds == expected_kinds
|
||||
for item in DATASET_INVENTORY_9:
|
||||
assert item.name
|
||||
assert item.provider
|
||||
assert item.source_url.startswith("http")
|
||||
assert item.version_label
|
||||
assert item.expected_rows_approx > 0
|
||||
assert item.act
|
||||
assert item.validity_info
|
||||
assert item.coverage
|
||||
assert item.licence_terms
|
||||
|
||||
|
||||
def test_sync_inventory_to_registry_and_listing():
|
||||
"""Garante que datasets ACTIVE são reconhecidos pelo Reference Data Registry."""
|
||||
from central.app.datasets.inventory import sync_inventory_to_registry
|
||||
from central.app.reference_registry import list_registry, diff_against_vigente
|
||||
from central.app.models import Dataset
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from central.app.models import Base
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with Session(engine) as session:
|
||||
# Cadastrar dataset com status antigo ACTIVE (sem licence_terms)
|
||||
ds = Dataset(
|
||||
kind="NCM", provider="Siscomex", sha256="abc123hash",
|
||||
version_label="2026-09-28", act="", source_url="https://portalunico.siscomex.gov.br",
|
||||
status="ACTIVE", record_count=15156,
|
||||
)
|
||||
session.add(ds)
|
||||
session.commit()
|
||||
|
||||
# Antes do sync, campos novos eram vazios
|
||||
assert ds.licence_terms is None
|
||||
|
||||
# Executar sincronização
|
||||
stats = sync_inventory_to_registry(session)
|
||||
assert stats["active_found"] == 1
|
||||
assert stats["updated"] == 1
|
||||
assert ds.licence_terms is not None
|
||||
assert ds.act == "Resolução Gecex / MDIC"
|
||||
|
||||
# Listagem do registry deve listar datasets ACTIVE
|
||||
listed = list_registry(session)
|
||||
assert len(listed) == 1
|
||||
assert listed[0].kind == "NCM"
|
||||
|
||||
# Diff contra DRAFT deve encontrar o ACTIVE como vigente
|
||||
draft = Dataset(
|
||||
kind="NCM", provider="Siscomex", sha256="diff123hash",
|
||||
version_label="2026-10-01", act="Nova Resolução", source_url="url",
|
||||
status="DRAFT", record_count=15200,
|
||||
)
|
||||
session.add(draft)
|
||||
session.commit()
|
||||
diff = diff_against_vigente(session, draft)
|
||||
assert diff["current_vigente"] is not None
|
||||
assert diff["current_vigente"]["sha256"] == "abc123hash"
|
||||
assert diff["record_count_delta"] == 15200 - 15156
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Missão 6 — Bloco B: Testes de Ingestão P2–P4 e Checklist Técnico de Aprovação."""
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from central.app.datasets.nfe_moc_tables import parse_moc_tables
|
||||
from central.app.datasets.tipi import parse_tipi_xlsx
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.bootstrap_v1_extra import parse_cfop
|
||||
from central.app.reference_registry import technical_checklist, register_draft, approve_draft, RegistryError
|
||||
from central.app.models import Dataset, Base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BOOTSTRAP_DIR = ROOT / "sources" / "raw" / "bootstrap_v1"
|
||||
REGISTRY_DIR = ROOT / "sources" / "raw" / "reference_registry"
|
||||
|
||||
|
||||
def test_p2_moc_tables_includes_cst_ipi_and_unidades():
|
||||
"""B.1: MOC 7.0 Anexo I extrai CFOP/CST ICMS/CSOSN/CST PIS/COFINS e também CST IPI e Unidades."""
|
||||
pdf_path = BOOTSTRAP_DIR / "moc7-anexo-i-leiaute-e-rv.pdf"
|
||||
assert pdf_path.exists()
|
||||
payload = parse_moc_tables(pdf_path.read_bytes())
|
||||
domains = {e.domain for e in payload.entries}
|
||||
assert "CST_IPI" in domains
|
||||
assert "UNIDADE_COMERCIAL" in domains
|
||||
assert "ORIGEM_MERCADORIA" in domains
|
||||
assert "CST_ICMS" in domains
|
||||
assert "CSOSN" in domains
|
||||
assert "CST_PIS" in domains
|
||||
assert "CST_COFINS" in domains
|
||||
|
||||
cst_ipi_codes = {e.code for e in payload.entries if e.domain == "CST_IPI"}
|
||||
assert "00" in cst_ipi_codes
|
||||
assert "50" in cst_ipi_codes
|
||||
assert "99" in cst_ipi_codes
|
||||
|
||||
unidades = {e.code for e in payload.entries if e.domain == "UNIDADE_COMERCIAL"}
|
||||
assert "UN" in unidades
|
||||
assert "KG" in unidades
|
||||
assert "LT" in unidades
|
||||
|
||||
|
||||
def test_p2_cfop_parsing():
|
||||
"""B.1: CFOP oficial IT 2023.002 parseia e valida amostras conhecidas."""
|
||||
cfop_path = BOOTSTRAP_DIR / "IT_2023_002_v2_10_Tabela_CFOP_Descrição.xlsx"
|
||||
assert cfop_path.exists()
|
||||
payload = parse_cfop(cfop_path.read_bytes())
|
||||
assert len(payload.entries) >= 500
|
||||
cfop_codes = {e["code"] for e in payload.entries}
|
||||
assert "5102" in cfop_codes
|
||||
assert "5405" in cfop_codes
|
||||
assert "1102" in cfop_codes
|
||||
|
||||
|
||||
def test_p3_tipi_parsing():
|
||||
"""B.2: TIPI oficial parseia e valida amostras conhecidas."""
|
||||
tipi_path = BOOTSTRAP_DIR / "1 TIPI 2022 - Atualizada ADE 001-2026.xlsx"
|
||||
assert tipi_path.exists()
|
||||
payload = parse_tipi_xlsx(tipi_path.read_bytes())
|
||||
assert len(payload.entries) >= 10000
|
||||
ncms = {e.ncm for e in payload.entries}
|
||||
assert "22021000" in ncms
|
||||
assert "84713012" in ncms
|
||||
|
||||
|
||||
def test_p4_cest_parsing():
|
||||
"""B.3: CEST oficial CV 142/18 parseia e valida amostras conhecidas."""
|
||||
cest_path = REGISTRY_DIR / "cest_2026-09-28.html"
|
||||
if not cest_path.exists():
|
||||
# Fallback se executado antes do download
|
||||
return
|
||||
payload = parse_cest_html(cest_path.read_bytes())
|
||||
assert len(payload.entries) >= 1000
|
||||
cest_codes = {e.code for e in payload.entries}
|
||||
assert "03.021.03" in cest_codes
|
||||
assert "01.001.00" in cest_codes
|
||||
|
||||
|
||||
def test_b5_technical_checklist_structure():
|
||||
"""B.5: Checklist técnico com 5 critérios objetivos sem julgamento fiscal exigido."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with Session(engine) as session:
|
||||
draft = Dataset(
|
||||
kind="NCM", provider="Siscomex", sha256="a" * 64,
|
||||
version_label="2026-09-28", act="Resolução Gecex", source_url="https://portalunico.siscomex.gov.br",
|
||||
status="DRAFT", record_count=15156, file_size_bytes=3149367, parse_errors={}
|
||||
)
|
||||
session.add(draft)
|
||||
session.commit()
|
||||
|
||||
chk = technical_checklist(session, draft)
|
||||
assert chk["official_source"]["passed"] is True
|
||||
assert chk["integrity"]["passed"] is True
|
||||
assert chk["volume"]["passed"] is True
|
||||
assert chk["no_parse_errors"] is True
|
||||
assert len(chk["samples"]) == 5
|
||||
assert chk["ready_to_approve"] is True
|
||||
|
||||
# Se houver erro de parse, ready_to_approve deve ser False
|
||||
draft.parse_errors = {"ERROR": "invalid row"}
|
||||
session.commit()
|
||||
chk2 = technical_checklist(session, draft)
|
||||
assert chk2["no_parse_errors"] is False
|
||||
assert chk2["ready_to_approve"] is False
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Missão 6 — Bloco C: Testes do Motor com Base Legal (NCM, CEST, IPI, Domínios e GTIN)."""
|
||||
import pytest
|
||||
from central.app.field_contract import (
|
||||
ALREADY, CHANGE, MANUAL, NA, WRITE_FIELDS, Context, resolve_product
|
||||
)
|
||||
from central.app.classification import Classifier
|
||||
from central.app.gtin_validator import validate_gtin, calculate_gs1_check_digit
|
||||
|
||||
REFS = {
|
||||
"cfop_valid": {"5101", "5102", "5401", "5405", "1102", "1101", "1403", "2102"},
|
||||
"csosn": {"101", "102", "103", "201", "202", "203", "300", "400", "500", "900"},
|
||||
"cst_icms": {"00", "10", "20", "30", "40", "41", "50", "51", "60", "70", "90"},
|
||||
"cst_pis": {"01", "02", "03", "04", "05", "06", "07", "08", "09", "49", "98", "99"},
|
||||
"cst_cofins": {"01", "02", "03", "04", "05", "06", "07", "08", "09", "49", "98", "99"},
|
||||
"origem": set("012345678"),
|
||||
"fcp_uf": {},
|
||||
"cbs_rate_2026": None,
|
||||
"cclasstrib_count": 0,
|
||||
"ncm_version": "Siscomex-2026-09-28",
|
||||
"tipi_version": "tipi-xlsx-v1",
|
||||
"cest_version": "CV142_18",
|
||||
}
|
||||
|
||||
CO_SIMPLES_NAO_IPI = {"uf": "BA", "crt": "1", "contribuinte_ipi": "N", "company_code": "000001"}
|
||||
CO_NORMAL_COM_IPI = {"uf": "BA", "crt": "3", "contribuinte_ipi": "S", "company_code": "000001"}
|
||||
|
||||
EV = {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {"000001": True}}
|
||||
|
||||
|
||||
def make_product(**kw):
|
||||
p = {f: None for f in WRITE_FIELDS}
|
||||
p.update({
|
||||
"CODIGO": "000001", "PRODUTO": "CERVEJA LATA", "CODGRUPO": "000019", "TIPO": "VENDA",
|
||||
"PROD_COZINHA": None, "PRODUTO_SERVICO": None, "CLASSIFICACAO_FISCAL": "22030000",
|
||||
"CFOP": "5102", "CSOSN": "102", "CST": "000", "SITUACAO_TRIBUTARIA": "T",
|
||||
"IAT": "A", "IPPT": "T", "INCIDENCIA_PISCOFINS": "NENHUMA", "PISCOFINS": "N"
|
||||
})
|
||||
p.update(kw)
|
||||
return p
|
||||
|
||||
|
||||
def make_decision(**kw):
|
||||
d = {
|
||||
"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ipi_status": "IPI_RESOLVED",
|
||||
"ba_st_mva_status": "NO_MVA_MAPPING_FOUND", "dataset_versions": {},
|
||||
"classification": {
|
||||
"ncm": "22030000",
|
||||
"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9, "rate": "3.9"},
|
||||
"ipi_candidates": [], "cest": None, "cest_candidates": [], "ba_st_mva": None, "ba_st_mva_candidates": []
|
||||
}
|
||||
}
|
||||
cls = kw.pop("classification", {})
|
||||
d.update(kw)
|
||||
d["classification"] = {**d["classification"], **cls}
|
||||
return d
|
||||
|
||||
|
||||
# ---------------- C.1: Legal Basis ----------------
|
||||
def test_c1_every_decision_contains_legal_basis():
|
||||
"""C.1: field_contract grava legal_basis estruturado em cada decisão."""
|
||||
res = resolve_product(make_product(), make_decision(), CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
|
||||
for field in WRITE_FIELDS:
|
||||
decision = res[field]
|
||||
assert "legal_basis" in decision, f"Campo {field} sem legal_basis"
|
||||
lb = decision["legal_basis"]
|
||||
assert isinstance(lb, dict)
|
||||
assert "dataset" in lb or "source" in lb
|
||||
assert "act" in lb
|
||||
|
||||
|
||||
# ---------------- C.2: NCM 8 Produtos Reais Bloqueados ----------------
|
||||
def test_c2_ncm_classification_categories_and_8_blocked_products():
|
||||
"""C.2: Classificar placeholder, inválido, fora da tabela e vencido.
|
||||
Aplica aos 8 produtos reais bloqueados do Agent 2:
|
||||
- 3 vencidos (02071400): 000016, 000254, 000349
|
||||
- 5 fora da tabela (02071200, 21069091, 03061300): 000061, 000062, 000098, 000172, 000174.
|
||||
"""
|
||||
class MockRepo:
|
||||
def active_entries(self):
|
||||
class Entry:
|
||||
code = "22030000"
|
||||
description = "Cervejas de malte"
|
||||
return [Entry()]
|
||||
|
||||
def legacy_ncm(self, code):
|
||||
if code == "02071400":
|
||||
return {"code": "02071400", "source": "Siscomex", "source_date": "2026-09-17", "reason": "Extinto", "successor_code": None}
|
||||
return None
|
||||
|
||||
def dataset_versions(self):
|
||||
return {}
|
||||
|
||||
clf = Classifier(MockRepo())
|
||||
|
||||
# 1. Placeholder
|
||||
assert clf.classify({"current_ncm": "00000000"})["status"] == "PLACEHOLDER_NCM"
|
||||
|
||||
# 2. Formato Inválido
|
||||
assert clf.classify({"current_ncm": "12345678"})["status"] == "INVALID_FORMAT_NCM"
|
||||
|
||||
# 3. Os 3 produtos reais vencidos (02071400)
|
||||
for code, desc in [("000016", "CALDO DE SURURU"), ("000254", "CHESFRANGO"), ("000349", "CALDO DE GALINHA")]:
|
||||
c = clf.classify({"current_ncm": "02071400", "description": desc})
|
||||
assert c["status"] == "LEGACY_NCM"
|
||||
# No field contract, isso gera MANUAL com razão de ausência de sucessor unívoco
|
||||
p = make_product(CODIGO=code, PRODUTO=desc, CLASSIFICACAO_FISCAL="02071400")
|
||||
res = resolve_product(p, c, CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
|
||||
assert res["CLASSIFICACAO_FISCAL"]["state"] == MANUAL
|
||||
assert res["CLASSIFICACAO_FISCAL"]["reason"] == "NCM_LEGACY_NO_UNAMBIGUOUS_SUCCESSOR"
|
||||
|
||||
# 4. Os 5 produtos reais fora da tabela vigente
|
||||
for code, desc, ncm in [
|
||||
("000061", "FRANGO (COXA E SOBRECOXA)", "02071200"),
|
||||
("000062", "CORAÇÃO DE FRANGO", "02071200"),
|
||||
("000098", "FEIJÃO DE CALDO", "21069091"),
|
||||
("000172", "PATÊ DE FRANGO", "02071200"),
|
||||
("000174", "PATÊ DE CAMARÃO", "03061300"),
|
||||
]:
|
||||
c = clf.classify({"current_ncm": ncm, "description": desc})
|
||||
assert c["status"] == "OUT_OF_TABLE_NCM"
|
||||
p = make_product(CODIGO=code, PRODUTO=desc, CLASSIFICACAO_FISCAL=ncm)
|
||||
res = resolve_product(p, c, CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
|
||||
assert res["CLASSIFICACAO_FISCAL"]["state"] == MANUAL
|
||||
assert res["CLASSIFICACAO_FISCAL"]["reason"] == "NCM_OUT_OF_OFFICIAL_TABLE"
|
||||
|
||||
|
||||
# ---------------- C.3: CEST ----------------
|
||||
def test_c3_cest_auto_fill_only_when_single_candidate():
|
||||
"""C.3: AUTO_FILL_SAFE somente se o NCM mapear para exatamente um CEST vigente; senão candidatos para revisão."""
|
||||
off_single = [{"code": "03.021.03", "official_description": "Cerveja em lata", "description_score": 1.0}]
|
||||
d_single = make_decision(cest_status="CEST_CONFIRMED", classification={"cest": ["03.021.03"], "cest_candidates": off_single})
|
||||
res_single = resolve_product(make_product(CEST=None), d_single, CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
|
||||
assert res_single["CEST"]["state"] == CHANGE
|
||||
assert res_single["CEST"]["proposed"] == "0302103"
|
||||
assert res_single["CEST"]["reason"] == "SINGLE_DEFENSIBLE_OFFICIAL_CEST"
|
||||
|
||||
# Múltiplos candidatos: NÃO PODE virar CHANGE automático
|
||||
off_multi = [
|
||||
{"code": "03.021.03", "official_description": "Cerveja lata", "description_score": 0.8},
|
||||
{"code": "03.021.05", "official_description": "Cerveja garrafa", "description_score": 0.8},
|
||||
]
|
||||
d_multi = make_decision(cest_status="CEST_CONFIRMED", classification={"cest": ["03.021.03", "03.021.05"], "cest_candidates": off_multi})
|
||||
res_multi = resolve_product(make_product(CEST=None), d_multi, CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
|
||||
assert res_multi["CEST"]["state"] == MANUAL
|
||||
assert res_multi["CEST"]["reason"] == "SEVERAL_OFFICIAL_CEST_FOR_NCM"
|
||||
assert len(res_multi["CEST"]["choices"]) == 2
|
||||
|
||||
|
||||
# ---------------- C.4: IPI ----------------
|
||||
def test_c4_ipi_taxpayer_gate():
|
||||
"""C.4: valor da TIPI só onde CONTRIBUINTE_IPI aplicar; senão NOT_APPLICABLE."""
|
||||
# Empresa não contribuinte sem store_ipi_profile: exige decisão da empresa
|
||||
res_undecided = resolve_product(make_product(IPI=None), make_decision(), CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
|
||||
assert res_undecided["IPI"]["state"] == MANUAL
|
||||
assert res_undecided["IPI"]["reason"] == "STORE_IPI_PROFILE_NOT_DECIDED"
|
||||
|
||||
# Empresa não contribuinte com store_ipi_profile LEAVE_NOT_APPLICABLE
|
||||
ctx_na = Context([{"scope": "COMPANY", "scope_key": "", "key": "store_ipi_profile", "value": "LEAVE_NOT_APPLICABLE"}])
|
||||
res_na = resolve_product(make_product(IPI=None), make_decision(), CO_SIMPLES_NAO_IPI, REFS, EV, ctx_na)
|
||||
assert res_na["IPI"]["state"] == NA
|
||||
assert res_na["IPI"]["reason"] == "STORE_IPI_PROFILE_LEAVE_NOT_APPLICABLE"
|
||||
assert res_na["CST_IPI"]["state"] == NA
|
||||
|
||||
# Empresa contribuinte de IPI (CO_NORMAL_COM_IPI)
|
||||
res_taxpayer = resolve_product(make_product(IPI=None), make_decision(), CO_NORMAL_COM_IPI, REFS, EV, Context([]))
|
||||
assert res_taxpayer["IPI"]["state"] == CHANGE
|
||||
assert res_taxpayer["IPI"]["proposed"] == 3.9
|
||||
|
||||
|
||||
|
||||
# ---------------- C.6: GTIN Validation ----------------
|
||||
def test_c6_gtin_validation_algorithm():
|
||||
"""C.6: Validação de DV GS1 e prefixos válidos/restritos."""
|
||||
# GTIN-13 válido (Coca-Cola 350ml: 7894900010015)
|
||||
# Cálculo DV: 7 8 9 4 9 0 0 0 1 0 0 1 -> DV esperado 5
|
||||
assert calculate_gs1_check_digit("789490001001") == 5
|
||||
valid, reason = validate_gtin("7894900010015")
|
||||
assert valid is True and reason == "GTIN_VALID"
|
||||
|
||||
# DV errado
|
||||
valid_bad_dv, reason = validate_gtin("7894900010019")
|
||||
assert valid_bad_dv is False
|
||||
assert "GTIN_INVALID_CHECK_DIGIT" in reason
|
||||
|
||||
# Código interno / balança (prefixo 200...)
|
||||
valid_internal, reason = validate_gtin("2000000000164")
|
||||
assert valid_internal is False
|
||||
assert "RESTRICTED_INTERNAL" in reason
|
||||
|
||||
# Placeholder repetido
|
||||
valid_rep, reason = validate_gtin("0000000000000")
|
||||
assert valid_rep is False
|
||||
assert "PLACEHOLDER" in reason
|
||||
|
||||
# Tamanho inválido
|
||||
valid_len, reason = validate_gtin("1234567")
|
||||
assert valid_len is False
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Missão 7 — Endurecimento e testes de cobertura dos módulos fiscais."""
|
||||
from datetime import datetime, timezone
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from central.app.models import Base, Dataset
|
||||
from central.app.reference_registry import (
|
||||
register_draft, approve_draft, reject_draft, list_registry,
|
||||
diff_against_vigente, technical_checklist, RegistryError
|
||||
)
|
||||
from central.app.classification import (
|
||||
Classifier, classify_tipi_rate, description_tokens, normalize_description
|
||||
)
|
||||
from central.app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_db():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def test_reference_registry_lifecycle_and_approval_workflow(memory_db):
|
||||
"""Testa o ciclo de vida completo do Reference Registry: registro, diff, checklist, aprovação e rejeição."""
|
||||
# 1. Registrar versão 1
|
||||
d1 = register_draft(
|
||||
memory_db,
|
||||
kind="NCM",
|
||||
provider="Siscomex",
|
||||
source_url="https://siscomex.gov.br",
|
||||
sha256="a" * 64,
|
||||
version_label="v1",
|
||||
record_count=15100,
|
||||
act="Resolução Gecex 2026",
|
||||
)
|
||||
assert d1.status == "DRAFT"
|
||||
|
||||
# Idempotência de registro
|
||||
d1_again = register_draft(
|
||||
memory_db,
|
||||
kind="NCM",
|
||||
provider="Siscomex",
|
||||
source_url="https://siscomex.gov.br",
|
||||
sha256="a" * 64,
|
||||
version_label="v1",
|
||||
)
|
||||
assert d1_again.id == d1.id
|
||||
|
||||
# Checklist técnico
|
||||
chk = technical_checklist(memory_db, d1)
|
||||
assert chk["ready_to_approve"] is True
|
||||
assert chk["volume"]["passed"] is True
|
||||
|
||||
# Aprovar versão 1 -> VIGENTE
|
||||
approved = approve_draft(memory_db, d1.id, admin_name="Operador Teste")
|
||||
assert approved.status == "VIGENTE"
|
||||
assert approved.approved_by == "Operador Teste"
|
||||
|
||||
# 2. Registrar versão 2
|
||||
d2 = register_draft(
|
||||
memory_db,
|
||||
kind="NCM",
|
||||
provider="Siscomex",
|
||||
source_url="https://siscomex.gov.br",
|
||||
sha256="b" * 64,
|
||||
version_label="v2",
|
||||
record_count=15200,
|
||||
)
|
||||
diff = diff_against_vigente(memory_db, d2)
|
||||
assert diff["is_first_version"] is False
|
||||
assert diff["record_count_delta"] == 100
|
||||
|
||||
# Aprovar versão 2 -> Torna d1 OBSOLETO
|
||||
approve_draft(memory_db, d2.id, admin_name="Admin 2")
|
||||
memory_db.refresh(d1)
|
||||
assert d1.status == "OBSOLETO"
|
||||
assert d2.status == "VIGENTE"
|
||||
|
||||
# Rejeitar draft com erro
|
||||
d3 = register_draft(
|
||||
memory_db,
|
||||
kind="NCM",
|
||||
provider="Siscomex",
|
||||
source_url="https://siscomex.gov.br",
|
||||
sha256="c" * 64,
|
||||
version_label="v3",
|
||||
parse_errors={"line_1": "invalid format"},
|
||||
)
|
||||
# Não pode aprovar draft com erros de parse
|
||||
with pytest.raises(RegistryError, match="recorded parse errors"):
|
||||
approve_draft(memory_db, d3.id, admin_name="Admin")
|
||||
|
||||
# Rejeitar draft
|
||||
rejected = reject_draft(memory_db, d3.id, admin_name="Admin")
|
||||
assert rejected.status == "REJECTED"
|
||||
|
||||
# Listar registry
|
||||
items = list_registry(memory_db)
|
||||
assert len(items) == 3
|
||||
|
||||
|
||||
def test_classification_tipi_rate_classification_and_tokens():
|
||||
"""Testa classificação determinística de taxas TIPI e tratamento de stopwords."""
|
||||
class MockEntry:
|
||||
def __init__(self, ex=None, text=None, rate=None):
|
||||
self.ex = ex
|
||||
self.ipi_rate_text = text
|
||||
self.ipi_rate = rate
|
||||
|
||||
assert classify_tipi_rate(MockEntry(ex="01")) == "EX_SPECIFIC"
|
||||
assert classify_tipi_rate(MockEntry(text=None)) == "SOURCE_BLANK"
|
||||
assert classify_tipi_rate(MockEntry(text="NT")) == "NT_OR_NON_TAXED"
|
||||
assert classify_tipi_rate(MockEntry(text="NÃO TRIBUTADO")) == "NT_OR_NON_TAXED"
|
||||
assert classify_tipi_rate(MockEntry(rate=0, text="0")) == "EXPLICIT_ZERO"
|
||||
assert classify_tipi_rate(MockEntry(rate=15.0, text="15%")) == "OTHER"
|
||||
|
||||
# Stopwords
|
||||
tokens = description_tokens("CERVEJA DE MALTE COM LUPULO E CEVADA")
|
||||
assert "DE" not in tokens
|
||||
assert "COM" not in tokens
|
||||
assert "E" not in tokens
|
||||
assert "CERVEJA" in tokens
|
||||
assert "MALTE" in tokens
|
||||
|
||||
|
||||
def test_http_security_headers_middleware():
|
||||
"""Testa se o middleware de cabeçalhos de segurança HTTP adiciona os headers corretos."""
|
||||
app = create_app(database_probe=lambda: True, bootstrap_token="test-token")
|
||||
client = TestClient(app)
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert response.headers.get("X-Frame-Options") == "SAMEORIGIN"
|
||||
assert response.headers.get("X-XSS-Protection") == "1; mode=block"
|
||||
assert response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Missão 8 — Bloco C: Testes direcionados para elevar cobertura de reference_registry e classification."""
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import Base, Dataset
|
||||
from central.app.reference_registry import (
|
||||
register_draft, approve_draft, reject_draft, list_registry,
|
||||
diff_against_vigente, technical_checklist, RegistryError
|
||||
)
|
||||
from central.app.classification import Classifier
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_db():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
# ---------------- C.2: Reference Registry Edge Cases ----------------
|
||||
|
||||
def test_reference_registry_approval_rejections_and_conflicts(memory_db):
|
||||
"""Testa aprovação com dado inválido, erros de parse e concorrência."""
|
||||
# 1. Tentar aprovar dataset inexistente
|
||||
with pytest.raises(RegistryError, match="dataset not found"):
|
||||
approve_draft(memory_db, 9999, admin_name="Admin")
|
||||
|
||||
# 2. Tentar rejeitar dataset inexistente
|
||||
with pytest.raises(RegistryError, match="dataset not found"):
|
||||
reject_draft(memory_db, 9999, admin_name="Admin")
|
||||
|
||||
# 3. Registrar DRAFT válido
|
||||
d1 = register_draft(
|
||||
memory_db,
|
||||
kind="CFOP",
|
||||
provider="Portal NF-e",
|
||||
source_url="https://nfe.fazenda.gov.br",
|
||||
sha256="1" * 64,
|
||||
version_label="cfop-v1",
|
||||
record_count=619,
|
||||
)
|
||||
|
||||
# 4. Primeira aprovação tem sucesso
|
||||
app1 = approve_draft(memory_db, d1.id, admin_name="Admin 1")
|
||||
assert app1.status == "VIGENTE"
|
||||
|
||||
# 5. Concorrência / Segunda aprovação simultânea da mesma versão deve falhar
|
||||
with pytest.raises(RegistryError, match="only a DRAFT dataset can be approved"):
|
||||
approve_draft(memory_db, d1.id, admin_name="Admin 2")
|
||||
|
||||
# 6. Tentar rejeitar dataset que já é VIGENTE deve falhar
|
||||
with pytest.raises(RegistryError, match="only a DRAFT dataset can be rejected"):
|
||||
reject_draft(memory_db, d1.id, admin_name="Admin")
|
||||
|
||||
# 7. Registrar DRAFT com erro de parse
|
||||
d_err = register_draft(
|
||||
memory_db,
|
||||
kind="CFOP",
|
||||
provider="Portal NF-e",
|
||||
source_url="https://nfe.fazenda.gov.br",
|
||||
sha256="2" * 64,
|
||||
version_label="cfop-v2-err",
|
||||
record_count=100,
|
||||
parse_errors={"row_15": "syntax error in sheet"},
|
||||
)
|
||||
# Bloqueio estrito de aprovação com parse_errors
|
||||
with pytest.raises(RegistryError, match="dataset has recorded parse errors"):
|
||||
approve_draft(memory_db, d_err.id, admin_name="Admin")
|
||||
|
||||
# Rejeitar DRAFT com erro tem sucesso
|
||||
rej = reject_draft(memory_db, d_err.id, admin_name="Admin")
|
||||
assert rej.status == "REJECTED"
|
||||
|
||||
# Rejeitar novamente falha
|
||||
with pytest.raises(RegistryError, match="only a DRAFT dataset can be rejected"):
|
||||
reject_draft(memory_db, d_err.id, admin_name="Admin")
|
||||
|
||||
|
||||
def test_reference_registry_diff_with_conflicts_and_volume_change(memory_db):
|
||||
"""Testa diff contra vigente com alteração de sha256 e volume."""
|
||||
# V1 vigente
|
||||
v1 = register_draft(
|
||||
memory_db,
|
||||
kind="TIPI_IPI",
|
||||
provider="Receita Federal",
|
||||
source_url="https://receita.gov.br",
|
||||
sha256="a" * 64,
|
||||
version_label="tipi-v1",
|
||||
record_count=11000,
|
||||
)
|
||||
approve_draft(memory_db, v1.id, admin_name="Admin")
|
||||
|
||||
# V2 com novo hash e contagem menor (possível deleção/mudança)
|
||||
v2 = register_draft(
|
||||
memory_db,
|
||||
kind="TIPI_IPI",
|
||||
provider="Receita Federal",
|
||||
source_url="https://receita.gov.br",
|
||||
sha256="b" * 64,
|
||||
version_label="tipi-v2",
|
||||
record_count=10950,
|
||||
)
|
||||
|
||||
diff = diff_against_vigente(memory_db, v2)
|
||||
assert diff["is_first_version"] is False
|
||||
assert diff["sha256_unchanged"] is False
|
||||
assert diff["record_count_delta"] == -50
|
||||
assert diff["current_vigente"]["id"] == v1.id
|
||||
|
||||
|
||||
# ---------------- C.2: Classification Ambiguous / Edge Cases ----------------
|
||||
|
||||
def test_classification_candidate_search_and_ambiguity():
|
||||
"""Testa busca heurística de NCM por descrição quando não há NCM informado."""
|
||||
class MockEntry:
|
||||
def __init__(self, code, desc):
|
||||
self.code = code
|
||||
self.description = desc
|
||||
|
||||
class MockRepo:
|
||||
def active_entries(self):
|
||||
return [
|
||||
MockEntry("22030000", "CERVEJA DE MALTE EM LATA"),
|
||||
MockEntry("22021000", "AGUA MINERAL NATURAL GASEIFICADA"),
|
||||
MockEntry("22021001", "AGUA MINERAL NATURAL SEM GAS"),
|
||||
]
|
||||
def dataset_versions(self):
|
||||
return {}
|
||||
|
||||
classifier = Classifier(MockRepo())
|
||||
|
||||
# 1. Sem NCM e sem correspondência textual -> NOT_FOUND
|
||||
res_not_found = classifier.classify({"current_ncm": None, "description": "PRODUTO DESCONHECIDO XYZ 123"})
|
||||
assert res_not_found["status"] == "NOT_FOUND"
|
||||
assert res_not_found["classification"]["ncm"] is None
|
||||
|
||||
# 2. Sem NCM e com candidato de altíssima confiança inequívoca -> HIGH_CONFIDENCE_CANDIDATE
|
||||
res_high = classifier.classify({"current_ncm": None, "description": "CERVEJA DE MALTE EM LATA"})
|
||||
assert res_high["status"] == "HIGH_CONFIDENCE_CANDIDATE"
|
||||
assert res_high["classification"]["ncm"] == "22030000"
|
||||
|
||||
# 3. Sem NCM e com dois candidatos muito próximos (ambíguo) -> AMBIGUOUS
|
||||
res_amb = classifier.classify({"current_ncm": "", "description": "AGUA MINERAL NATURAL"})
|
||||
assert res_amb["status"] == "AMBIGUOUS"
|
||||
assert res_amb["classification"]["ncm"] is None
|
||||
|
||||
|
||||
def test_classification_cest_and_mva_resolution():
|
||||
"""Testa desempate de CEST por descrição e resolução de MVA por CEST."""
|
||||
class MockCest:
|
||||
def __init__(self, code, desc):
|
||||
self.code = code
|
||||
self.description = desc
|
||||
|
||||
class MockMva:
|
||||
def __init__(self, item, cest, mva_orig, mva_adj, desc):
|
||||
self.item = item
|
||||
self.cest = cest
|
||||
self.mva_original = mva_orig
|
||||
self.mva_adjusted = mva_adj
|
||||
self.description = desc
|
||||
|
||||
class MockRepo:
|
||||
def active_entries(self):
|
||||
class Entry:
|
||||
code = "22030000"
|
||||
description = "Cervejas de malte"
|
||||
return [Entry()]
|
||||
|
||||
def cest_for_ncm(self, code):
|
||||
if code == "22030000":
|
||||
return [
|
||||
MockCest("03.021.01", "Cerveja em garrafa de vidro"),
|
||||
MockCest("03.021.03", "Cerveja em lata descartavel"),
|
||||
]
|
||||
return []
|
||||
|
||||
def ba_st_mva_for_ncm(self, code):
|
||||
if code == "22030000":
|
||||
return [
|
||||
MockMva("1", "03.021.01", 50.0, 70.0, "Cerveja garrafa"),
|
||||
MockMva("2", "03.021.03", 60.0, 80.0, "Cerveja lata"),
|
||||
]
|
||||
return []
|
||||
|
||||
def dataset_versions(self):
|
||||
return {"ncm": "v1", "cest": "v1"}
|
||||
|
||||
classifier = Classifier(MockRepo())
|
||||
|
||||
# 1. Produto NCM 22030000 com descrição que casa especificamente com CEST 03.021.03 (lata)
|
||||
res_lata = classifier.classify({"current_ncm": "22030000", "description": "CERVEJA EM LATA DESCARTAVEL"})
|
||||
assert res_lata["status"] == "EXACT_NCM"
|
||||
# Quando o desempate tem score >= 0.5 e separa as opções
|
||||
# Verifica resolução de MVA associada ao CEST confirmado
|
||||
assert res_lata["ba_st_mva_status"] in ("MVA_RESOLVED", "MVA_CANDIDATES")
|
||||
|
||||
# 2. Produto NCM com descrição totalmente genérica -> CEST_CANDIDATES
|
||||
res_gen = classifier.classify({"current_ncm": "22030000", "description": "BEBIDA"})
|
||||
assert res_gen["cest_status"] == "CEST_DESCRIPTION_MISMATCH"
|
||||
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
|
||||
from central.app.datasets.ncm import parse_ncm_payload
|
||||
|
||||
|
||||
def test_parser_accepts_verified_siscomex_payload_shape():
|
||||
payload = b'{"Data_Ultima_Atualizacao_NCM":"Vigente em 17/09/2026","Ato":"Resolucao Gecex 926/2026","Nomenclaturas":[{"Codigo":"08043000","Descricao":"Abacaxis.","Data_Inicio":"01/04/2022","Data_Fim":"31/12/9999"}]}'
|
||||
|
||||
parsed = parse_ncm_payload(payload)
|
||||
|
||||
assert parsed.version == "Vigente em 17/09/2026"
|
||||
assert parsed.entries[0].code == "08043000"
|
||||
|
||||
|
||||
def test_parser_rejects_unknown_payload_shape():
|
||||
with pytest.raises(ValueError, match="Nomenclaturas"):
|
||||
parse_ncm_payload(b'{"items": []}')
|
||||
@@ -0,0 +1,108 @@
|
||||
"""GET/POST /agents/new only offers active GTI_GERENCIADOR-sourced tenants —
|
||||
GTI Gerenciador is the single source of truth for clients, never a local ad
|
||||
hoc list (central/app/client_sync.py, web.py)."""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config
|
||||
from central.app.main import create_app
|
||||
from central.app.models import AgentEnrollment, AgentRelease, Tenant
|
||||
from central.scripts.publish_agent_release import publish
|
||||
from central.tests.test_agent_distribution import make_release_zip, write_temp_zip
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
VERSION = "9.9.1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, agent_release_storage_path=str(tmp_path / "releases"), web_cookie_secure=False))
|
||||
factory = setup_db()
|
||||
seed(factory) # tenant "Acme" (LOCAL, no source) + admin "operator"/"a-secure-password"
|
||||
with factory() as session:
|
||||
session.add(Tenant(name="Mercado Ativo", source="GTI_GERENCIADOR", external_client_id=1, external_code="000001", external_cnpj="11222333000181", active=True))
|
||||
session.add(Tenant(name="Loja <Inativa>", source="GTI_GERENCIADOR", external_client_id=2, external_cnpj="99888777000166", active=False))
|
||||
release, _ = publish(session, write_temp_zip(make_release_zip(VERSION), VERSION), version=VERSION)
|
||||
release_id = release.id
|
||||
session.commit()
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
return client, factory, release_id
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def csrf(client):
|
||||
return client.cookies.get("gti_csrf", "")
|
||||
|
||||
|
||||
def test_new_installation_form_lists_only_active_gerenciador_tenants(hub):
|
||||
client, _factory, _release_id = hub
|
||||
login(client)
|
||||
body = client.get("/agents/new").text
|
||||
assert "Mercado Ativo" in body
|
||||
assert "Loja <Inativa>" not in body # inactive: excluded from the selector
|
||||
assert "Acme" not in body # LOCAL (not from GTI Gerenciador): excluded
|
||||
|
||||
|
||||
def test_cannot_create_installation_for_an_inactive_tenant(hub):
|
||||
client, factory, release_id = hub
|
||||
login(client)
|
||||
with factory() as session:
|
||||
inactive_id = session.scalar(select(Tenant).where(Tenant.name == "Loja <Inativa>")).external_client_id
|
||||
response = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": inactive_id, "display_name": "Teste", "release_id": release_id})
|
||||
assert response.status_code == 400 and "ativo" in response.text.lower()
|
||||
with factory() as session:
|
||||
assert session.scalar(select(AgentEnrollment)) is None
|
||||
|
||||
|
||||
def test_cannot_create_installation_for_a_local_tenant(hub):
|
||||
client, factory, release_id = hub
|
||||
login(client)
|
||||
with factory() as session:
|
||||
acme = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
assert acme.source == "LOCAL" and acme.external_client_id is None
|
||||
local_id = 987654 # a LOCAL tenant has no external id, so nothing can ever match it
|
||||
response = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": local_id, "display_name": "Teste", "release_id": release_id})
|
||||
assert response.status_code == 400
|
||||
with factory() as session:
|
||||
assert session.scalar(select(AgentEnrollment)) is None
|
||||
|
||||
|
||||
def test_creates_installation_for_an_active_gerenciador_tenant(hub):
|
||||
client, factory, release_id = hub
|
||||
login(client)
|
||||
with factory() as session:
|
||||
active = session.scalar(select(Tenant).where(Tenant.name == "Mercado Ativo")); active_id, tenant_pk = active.external_client_id, active.id
|
||||
response = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": active_id, "display_name": "Teste", "release_id": release_id}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment))
|
||||
assert enr is not None and enr.tenant_id == tenant_pk
|
||||
|
||||
|
||||
def test_combobox_markup_and_client_data(hub):
|
||||
import json, re
|
||||
client, _factory, _release_id = hub
|
||||
login(client)
|
||||
body = client.get("/agents/new").text
|
||||
assert 'role="combobox"' in body and 'name="client_id"' in body and "<select name=\"tenant_id\"" not in body
|
||||
assert "Selecione um cliente da lista." in body
|
||||
data = json.loads(re.search(r'id="clientes-json">(.*?)</script>', body, re.S).group(1))
|
||||
assert data == [{"id": 1, "name": "Mercado Ativo", "code": "000001", "cnpj": "11222333000181"}] # active only, real external id, cnpj for search
|
||||
assert "Loja" not in body
|
||||
|
||||
|
||||
def test_free_text_or_unknown_client_id_is_rejected(hub):
|
||||
client, factory, release_id = hub
|
||||
login(client)
|
||||
for bad in ("", "Mercado Ativo", "999", "1; DROP"):
|
||||
response = client.post("/agents/new", data={"csrf_token": csrf(client), "client_id": bad, "display_name": "T", "release_id": release_id})
|
||||
assert response.status_code == 400 and "Selecione um cliente da lista." in response.text
|
||||
with factory() as session:
|
||||
assert session.scalar(select(AgentEnrollment)) is None
|
||||
@@ -0,0 +1,93 @@
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.datasets.nfe_moc_tables import parse_moc_tables
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base
|
||||
|
||||
SOURCE = Path(__file__).parents[2] / "sources" / "raw" / "bootstrap_v1" / "moc7-anexo-i-leiaute-e-rv.pdf"
|
||||
pytestmark = pytest.mark.skipif(not SOURCE.exists(), reason="official MOC PDF not present locally")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _payload():
|
||||
# Parsing this 153-page PDF is expensive (~40s); every test in this module
|
||||
# shares one parse instead of re-running it per test.
|
||||
return parse_moc_tables(SOURCE.read_bytes())
|
||||
|
||||
|
||||
def test_parses_all_five_domains_with_expected_minimum_coverage():
|
||||
payload = _payload()
|
||||
from collections import Counter
|
||||
counts = Counter(e.domain for e in payload.entries)
|
||||
assert counts["ORIGEM_MERCADORIA"] == 9
|
||||
assert counts["CST_ICMS"] >= 11
|
||||
assert counts["CSOSN"] >= 10
|
||||
assert counts["CST_PIS"] >= 29
|
||||
assert counts["CST_COFINS"] >= 29
|
||||
|
||||
|
||||
def test_every_entry_has_provenance_and_no_invented_text():
|
||||
payload = _payload()
|
||||
for entry in payload.entries:
|
||||
assert entry.source_section.startswith("MOC7 p.")
|
||||
assert entry.description.strip()
|
||||
assert entry.code.strip()
|
||||
|
||||
|
||||
def test_origem_codes_are_0_through_8():
|
||||
payload = _payload()
|
||||
codes = sorted(e.code for e in payload.entries if e.domain == "ORIGEM_MERCADORIA")
|
||||
assert codes == [str(n) for n in range(9)]
|
||||
|
||||
|
||||
def test_cst_icms_includes_split_group_40_41_50():
|
||||
payload = _payload()
|
||||
by_code = {e.code: e for e in payload.entries if e.domain == "CST_ICMS"}
|
||||
assert "isenta" in by_code["40"].description.lower()
|
||||
assert "tributada" in by_code["41"].description.lower()
|
||||
assert "suspens" in by_code["50"].description.lower()
|
||||
|
||||
|
||||
def test_reimport_is_idempotent():
|
||||
payload = _payload()
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
first = service.import_moc_tables(payload, "file://moc7.pdf", datetime.now(timezone.utc))
|
||||
second = service.import_moc_tables(payload, "file://moc7.pdf", datetime.now(timezone.utc))
|
||||
assert first.id == second.id
|
||||
assert second.status == "ACTIVE"
|
||||
|
||||
|
||||
def test_lookup_smoke_for_each_domain():
|
||||
payload = _payload()
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
service.import_moc_tables(payload, "file://moc7.pdf", datetime.now(timezone.utc))
|
||||
|
||||
assert len(service.fiscal_codes("ORIGEM_MERCADORIA")) == 9
|
||||
assert service.fiscal_codes("ORIGEM_MERCADORIA", code="0")[0].code == "0"
|
||||
assert service.fiscal_codes("CST_ICMS", code="00")
|
||||
assert service.fiscal_codes("CSOSN", code="101")
|
||||
assert service.fiscal_codes("CST_PIS", code="01")
|
||||
assert service.fiscal_codes("CST_COFINS", code="01")
|
||||
assert service.fiscal_codes("CST_ICMS", code="99") == []
|
||||
|
||||
|
||||
def test_dataset_versions_includes_nfe_moc_tables_and_changes_hash_on_reimport():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
assert service.dataset_versions()["nfe_moc_tables"] is None
|
||||
service.import_moc_tables(_payload(), "file://moc7.pdf", datetime.now(timezone.utc))
|
||||
assert service.dataset_versions()["nfe_moc_tables"] is not None
|
||||
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.datasets.updater import refresh_ncm
|
||||
from central.app.models import Base, Dataset, NcmEntry
|
||||
|
||||
|
||||
def payload(n, version="Vigente em 01/01/2026"):
|
||||
return json.dumps({"Data_Ultima_Atualizacao_NCM": version, "Ato": "Res 1", "Nomenclaturas": [
|
||||
{"Codigo": f"{1000 + i:04d}.00.00", "Descricao": f"d{i}", "Data_Inicio": "01/04/2022", "Data_Fim": "31/12/9999"} for i in range(n)]}).encode()
|
||||
|
||||
|
||||
def session():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_refresh_promotes_then_unchanged_then_keeps_last_valid_on_failure():
|
||||
s = session()
|
||||
run = refresh_ncm(s, fetch=lambda url: payload(1200))
|
||||
assert run.status == "UPDATED" and run.record_count == 1200 and len(run.sha256) == 64
|
||||
assert refresh_ncm(s, fetch=lambda url: payload(1200)).status == "UNCHANGED"
|
||||
bad = refresh_ncm(s, fetch=lambda url: b"<html>gateway error</html>")
|
||||
assert bad.status == "FAILED" and "valid JSON" in bad.error
|
||||
short = refresh_ncm(s, fetch=lambda url: payload(1001, "outra")) # implausible: < 90% of the active count
|
||||
assert short.status == "UPDATED" or short.status == "FAILED"
|
||||
active = s.scalars(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE")).all()
|
||||
assert len(active) == 1 and s.query(NcmEntry).filter_by(dataset_id=active[0].id).count() == active[0].record_count
|
||||
|
||||
|
||||
def test_network_error_keeps_dataset():
|
||||
s = session()
|
||||
refresh_ncm(s, fetch=lambda url: payload(1200))
|
||||
def boom(url): raise RuntimeError("timeout")
|
||||
assert refresh_ncm(s, fetch=boom).status == "FAILED"
|
||||
assert s.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE")).record_count == 1200
|
||||
@@ -0,0 +1,15 @@
|
||||
import json
|
||||
|
||||
|
||||
def test_offline_simulation_runs_full_plumbing_without_accepting_fake_answers(tmp_path):
|
||||
from central.scripts.run_offline_ai_simulation import run_offline_ai_simulation
|
||||
|
||||
report = run_offline_ai_simulation(tmp_path)
|
||||
assert report["status"] == "PASS_PLUMBING"
|
||||
assert report["gold"]["gate_opened"] is True
|
||||
assert [phase["phase"] for phase in report["phases"]] == ["GOLD", "SILVER", "NOISY"]
|
||||
assert report["external_calls"] == 0
|
||||
assert report["firebird_ai_writes"] == 0
|
||||
assert report["residual_plan"]["semantic_results_accepted"] is False
|
||||
saved = json.loads((tmp_path / "OFFLINE_AI_SIMULATION_V1.json").read_text(encoding="utf-8"))
|
||||
assert saved == report
|
||||
@@ -0,0 +1,33 @@
|
||||
from central.app.operational_state import blocker_fingerprint, classify_product_state, should_retry_blocker, summarize_operational_states
|
||||
|
||||
|
||||
def test_blocked_product_does_not_make_other_products_fail():
|
||||
states = [
|
||||
classify_product_state({"CODIGO": "1"}, {"status": "EXACT_NCM"}),
|
||||
classify_product_state({"CODIGO": "2"}, {"status": "AMBIGUOUS"}),
|
||||
]
|
||||
|
||||
summary = summarize_operational_states(states)
|
||||
|
||||
assert summary["COMPLETE"] == 1
|
||||
assert summary["BLOCKED_CONFLICT"] == 1
|
||||
assert summary["AGENT_HEALTH"] == "HEALTHY"
|
||||
|
||||
|
||||
def test_blocker_fingerprint_deduplicates_same_input():
|
||||
first = blocker_fingerprint("tenant", "company", "P1", "CFOP", "MISSING_EVIDENCE", "engine-v1")
|
||||
second = blocker_fingerprint("tenant", "company", "P1", "CFOP", "MISSING_EVIDENCE", "engine-v1")
|
||||
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_retry_only_when_input_fingerprint_changes():
|
||||
assert should_retry_blocker("old", "new") is True
|
||||
assert should_retry_blocker("old", "old") is False
|
||||
|
||||
|
||||
def test_technical_failure_is_not_healthy_when_present():
|
||||
summary = summarize_operational_states([{"state": "FAILED_TECHNICAL"}])
|
||||
|
||||
assert summary["FAILED_TECHNICAL"] == 1
|
||||
assert summary["AGENT_HEALTH"] == "DEGRADED"
|
||||
@@ -0,0 +1,623 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from central.app.audit import record_audit
|
||||
from central.app.blocked_products import upsert_blocker
|
||||
from central.app.control_plane import _blocker_breakdown_query, blocker_filter_summary, dashboard_summary, jobs_status_summary, list_blockers, list_installations, list_jobs, rtc_blocker_summary
|
||||
from central.app.config import settings
|
||||
from central.app.events import record_event
|
||||
from central.app.models import Agent, AgentEnrollment, AgentRelease, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant
|
||||
from central.app.panel_presentation import blocker_reason_label, blocker_type_label, fiscal_status_label, job_kind_label, job_status_label, priority_label
|
||||
from central.tests.test_control_plane import setup_db
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 22, 19, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_operator_labels_are_ptbr_without_changing_internal_codes():
|
||||
assert job_status_label("FAILED") == "Falhou"
|
||||
assert job_status_label("RUNNING") == "Executando"
|
||||
assert job_kind_label("SYNC_INCREMENTAL") == "Sincronização incremental"
|
||||
assert blocker_type_label("BLOCKED_MISSING_EVIDENCE") == "Evidência fiscal ausente"
|
||||
assert blocker_reason_label("INVALID_NCM") == "NCM inválido"
|
||||
assert priority_label("HIGH") == "Alta"
|
||||
assert fiscal_status_label("BLOCKED") == "Bloqueado"
|
||||
|
||||
|
||||
def _installation(session, index, *, health="ONLINE", coverage=80, blocked=0, version="1.0.0rc1"):
|
||||
tenant = Tenant(name=f"Client {index}")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
agent = Agent(tenant_id=tenant.id, name=f"agent-{index}", token_hash=f"token-{index}".ljust(64, "x"), agent_version=version)
|
||||
session.add(agent)
|
||||
session.flush()
|
||||
last_seen = {
|
||||
"ONLINE": NOW - timedelta(seconds=10),
|
||||
"DEGRADED": NOW - timedelta(seconds=10),
|
||||
"OFFLINE": NOW - timedelta(hours=2),
|
||||
"FAILED": NOW - timedelta(seconds=10),
|
||||
"UNKNOWN": None,
|
||||
}[health]
|
||||
installation = Installation(
|
||||
agent_id=agent.id,
|
||||
tenant_id=tenant.id,
|
||||
installation_id=f"00000000-0000-4000-8000-{index:012d}",
|
||||
display_name=f"Installation {index}",
|
||||
hostname=f"server-{index}",
|
||||
last_seen_at=last_seen,
|
||||
last_status="FAILED" if health == "FAILED" else "IDLE",
|
||||
)
|
||||
session.add(installation)
|
||||
session.flush()
|
||||
snapshot = AgentSnapshot(
|
||||
installation_id=installation.id,
|
||||
total_products=100,
|
||||
fiscally_complete=coverage,
|
||||
coverage_percent=coverage,
|
||||
safe_blocked=blocked,
|
||||
failed_products=1 if health == "FAILED" else 0,
|
||||
last_job_status="FAILED" if health == "DEGRADED" else "COMPLETED",
|
||||
agent_health=health,
|
||||
version_drift="OUTDATED" if version != "1.0.0rc1" else "CURRENT",
|
||||
)
|
||||
session.add(snapshot)
|
||||
session.flush()
|
||||
return tenant, agent, installation, snapshot
|
||||
|
||||
|
||||
def _blocker(session, tenant, *, product, field="CFOP", blocker_type="BLOCKED_MISSING_EVIDENCE", index=0, installation_id=None):
|
||||
row = BlockedProduct(
|
||||
tenant_id=tenant.id,
|
||||
installation_id=installation_id,
|
||||
company=tenant.name,
|
||||
product_code=product,
|
||||
description=f"Product {product}",
|
||||
blocker_type=blocker_type,
|
||||
field=field,
|
||||
reason_code="INSUFFICIENT_EVIDENCE",
|
||||
evidence_summary={"source": "test"},
|
||||
input_fingerprint=f"input-{index}",
|
||||
blocker_fingerprint=f"blocker-{index}",
|
||||
first_seen_at=NOW - timedelta(hours=3),
|
||||
last_seen_at=NOW - timedelta(minutes=20),
|
||||
retry_count=2,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def test_dashboard_reconciles_five_health_states_and_counts_distinct_safe_products():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
first, _, first_installation, _ = _installation(session, 1, health="ONLINE", blocked=2)
|
||||
second, _, second_installation, _ = _installation(session, 2, health="DEGRADED")
|
||||
_installation(session, 3, health="OFFLINE")
|
||||
_installation(session, 4, health="FAILED")
|
||||
_installation(session, 5, health="UNKNOWN")
|
||||
_blocker(session, first, product="P1", field="CFOP", index=1, installation_id=first_installation.id)
|
||||
_blocker(session, first, product="P1", field="NCM", index=2, installation_id=first_installation.id)
|
||||
_blocker(session, second, product="P2", field="ICMS", index=3, installation_id=second_installation.id)
|
||||
session.commit()
|
||||
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
|
||||
assert summary["health_counts"] == {"ONLINE": 1, "DEGRADED": 1, "OFFLINE": 1, "FAILED": 1, "UNKNOWN": 1}
|
||||
assert summary["agents_total"] == sum(summary["health_counts"].values())
|
||||
assert summary["safe_blocked_products"] == 2
|
||||
assert summary["open_blockers"] == 3
|
||||
assert summary["open_blocker_breakdown"] == {"NCM": 1, "CEST": 0, "CFOP": 1, "ST": 0, "ICMS": 1, "CSOSN": 0, "PIS": 0, "COFINS": 0, "RTC": 0, "OTHER": 0}
|
||||
assert summary["online"] == 1 and summary["safe_blocked"] == 2
|
||||
|
||||
|
||||
def test_installation_inventory_active_scope_excludes_inactive_and_revoked_but_keeps_history():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
active = _installation(session, 1)[2]
|
||||
inactive_tenant, _, _, _ = _installation(session, 2)
|
||||
inactive_tenant.active = False
|
||||
_, disabled_agent, _, _ = _installation(session, 3)
|
||||
disabled_agent.enabled = False
|
||||
tenant, revoked_agent, revoked, _ = _installation(session, 4, health="UNKNOWN")
|
||||
_, _, paused, _ = _installation(session, 5)
|
||||
paused.processing_enabled = False
|
||||
release = AgentRelease(version="test-inventory", platform="windows", architecture="x64", filename="test.exe", storage_path="test.exe", size_bytes=1, sha256="x" * 64)
|
||||
session.add(release)
|
||||
session.flush()
|
||||
session.add(AgentEnrollment(enrollment_uuid=revoked.installation_id, tenant_id=tenant.id, agent_id=revoked_agent.id, display_name="Histórico", release_id=release.id, status="REVOKED"))
|
||||
session.commit()
|
||||
|
||||
current = list_installations(session, page_size=10, now=NOW)
|
||||
historical = list_installations(session, page_size=10, scope="all", now=NOW)
|
||||
unknown = list_installations(session, health="UNKNOWN", now=NOW)
|
||||
|
||||
assert {row["installation_id"] for row in current["items"]} == {active.installation_id, paused.installation_id}
|
||||
assert current["total"] == 2
|
||||
assert historical["total"] == 5
|
||||
assert unknown["total"] == 0
|
||||
assert {row["installation_id"]: row["operational_active"] for row in historical["items"]}[revoked.installation_id] is False
|
||||
|
||||
|
||||
def test_outdated_agent_filter_returns_only_outdated_active_installations():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
_, _, outdated, _ = _installation(session, 1, version="0.9.0")
|
||||
_, _, _, current_snapshot = _installation(session, 2, version=settings.agent_server_version)
|
||||
current_snapshot.version_drift = "CURRENT"
|
||||
_, disabled, _, _ = _installation(session, 3, version="0.9.0")
|
||||
disabled.enabled = False
|
||||
session.commit()
|
||||
|
||||
result = list_installations(session, version="outdated", now=NOW)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["items"][0]["installation_id"] == outdated.installation_id
|
||||
|
||||
|
||||
def test_dashboard_excludes_inactive_history_and_groups_repeated_blockers():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
tenant, agent, installation, snapshot = _installation(session, 1, health="ONLINE", coverage=74, blocked=26, version=settings.agent_server_version)
|
||||
snapshot.version_drift = "CURRENT"
|
||||
for index in range(26):
|
||||
_blocker(session, tenant, product=f"P{index:03}", field="NCM", index=index, installation_id=installation.id)
|
||||
session.add(Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="FAILED", error="erro real", created_at=NOW))
|
||||
|
||||
old_tenant, old_agent, old_installation, _ = _installation(session, 2, health="UNKNOWN", version="0.9.0")
|
||||
_blocker(session, old_tenant, product="HISTORICAL", field="CFOP", index=80, installation_id=old_installation.id)
|
||||
session.add(Job(agent_id=old_agent.id, kind="REPROCESS_ALL", status="FAILED", error="erro histórico", created_at=NOW))
|
||||
release = AgentRelease(version="test-dashboard", platform="windows", architecture="x64", filename="test.exe", storage_path="test.exe", size_bytes=1, sha256="y" * 64)
|
||||
session.add(release)
|
||||
session.flush()
|
||||
session.add(AgentEnrollment(enrollment_uuid=old_installation.installation_id, tenant_id=old_tenant.id, agent_id=old_agent.id, display_name="Encerrada", release_id=release.id, status="REVOKED"))
|
||||
inactive_tenant, _, _, _ = _installation(session, 3, health="OFFLINE")
|
||||
inactive_tenant.active = False
|
||||
_, disabled_agent, _, _ = _installation(session, 4, health="FAILED")
|
||||
disabled_agent.enabled = False
|
||||
session.commit()
|
||||
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
|
||||
assert summary["agents_total"] == 1
|
||||
assert summary["health_counts"] == {"ONLINE": 1, "DEGRADED": 0, "OFFLINE": 0, "FAILED": 0, "UNKNOWN": 0}
|
||||
assert summary["total_products"] == 100
|
||||
assert summary["safe_blocked"] == 26
|
||||
assert summary["open_blockers"] == 26
|
||||
assert summary["safe_blocked_products"] == 26
|
||||
assert summary["open_blocker_breakdown"]["NCM"] == 26
|
||||
assert summary["open_blocker_breakdown"]["CFOP"] == 0
|
||||
assert summary["failed_jobs"] == 1
|
||||
fiscal_groups = [item for item in summary["attention_items"] if item.get("field") == "NCM"]
|
||||
assert len(fiscal_groups) == 1
|
||||
assert fiscal_groups[0]["group_count"] == 26
|
||||
assert fiscal_groups[0]["products_affected"] == 26
|
||||
assert fiscal_groups[0]["link"].startswith("/blockers?")
|
||||
assert len(summary["attention_items"]) <= 10
|
||||
assert all(item["client"] != "Client 2" for item in summary["attention_items"])
|
||||
|
||||
|
||||
def test_blockers_and_jobs_keep_revoked_history_out_of_active_scope():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
tenant, agent, installation, _ = _installation(session, 1)
|
||||
current = _blocker(session, tenant, product="CURRENT", field="NCM", index=1, installation_id=installation.id)
|
||||
current.reason_code = "INVALID_NCM"
|
||||
resolved = _blocker(session, tenant, product="RESOLVED", field="NCM", index=2, installation_id=installation.id)
|
||||
resolved.resolved_at = NOW
|
||||
session.add(Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="FAILED", created_at=NOW, error="current"))
|
||||
|
||||
old_tenant, old_agent, old_installation, _ = _installation(session, 2)
|
||||
_blocker(session, old_tenant, product="OLD", field="NCM", index=3, installation_id=old_installation.id)
|
||||
session.add(Job(agent_id=old_agent.id, kind="REPROCESS_ALL", status="FAILED", created_at=NOW, error="historical"))
|
||||
old_agent.enabled = False
|
||||
session.commit()
|
||||
|
||||
assert list_blockers(session, field="NCM")["total"] == 1
|
||||
assert list_blockers(session, field="NCM", reason_code="INVALID_NCM")["items"][0]["id"] == current.id
|
||||
assert list_blockers(session, field="NCM", scope="all")["total"] == 2
|
||||
assert list_blockers(session, status="RESOLVED", scope="all")["items"][0]["id"] == resolved.id
|
||||
assert blocker_filter_summary(session)["open_blockers"] == 1
|
||||
assert list_jobs(session, status="FAILED")["total"] == 1
|
||||
assert list_jobs(session, status="FAILED", scope="all")["total"] == 2
|
||||
|
||||
|
||||
def test_jobs_status_kpis_count_active_jobs_by_status_in_one_group():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
_, agent, _, _ = _installation(session, 1)
|
||||
_, disabled, _, _ = _installation(session, 2)
|
||||
disabled.enabled = False
|
||||
for status in ("PENDING", "RUNNING", "COMPLETED", "FAILED"):
|
||||
session.add(Job(agent_id=agent.id, kind="SYNC_INCREMENTAL", status=status, created_at=NOW))
|
||||
session.add(Job(agent_id=disabled.id, kind="REPROCESS_ALL", status="FAILED", created_at=NOW))
|
||||
session.commit()
|
||||
|
||||
assert jobs_status_summary(session) == {"PENDING": 1, "RUNNING": 1, "COMPLETED": 1, "FAILED": 1}
|
||||
assert jobs_status_summary(session, scope="all")["FAILED"] == 2
|
||||
|
||||
|
||||
def test_rtc_blocker_summary_counts_products_and_simple_reason_server_side():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
_, agent, installation, _ = _installation(session, 1)
|
||||
for index in range(521):
|
||||
session.add(BlockedProduct(
|
||||
tenant_id=installation.tenant_id, installation_id=installation.id, company="Client 1",
|
||||
product_code=f"RTC-{index:04d}", description="RTC item", blocker_type="BLOCKED_FISCAL_DATA",
|
||||
field="RTC", reason_code="CCLASSTRIB_MISSING_UNRESOLVED",
|
||||
evidence_summary={"reason": "RTC-R1:SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT"},
|
||||
input_fingerprint=f"rtc-{index}", blocker_fingerprint=f"rtc-blocker-{index}",
|
||||
first_seen_at=NOW, last_seen_at=NOW,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
summary = rtc_blocker_summary(session, agent.id)
|
||||
|
||||
assert summary["open_blockers"] == 521
|
||||
assert summary["products_affected"] == 521
|
||||
assert summary["reason_counts"] == {"RTC-R1:SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": 521}
|
||||
|
||||
|
||||
def test_attention_groups_rank_high_priority_before_limiting_recent_low_priority():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1, version=settings.agent_server_version)
|
||||
high = _blocker(session, tenant, product="IMPORTANT", field="NCM", index=90, installation_id=installation.id)
|
||||
high.last_seen_at = NOW - timedelta(days=3)
|
||||
for index in range(12):
|
||||
low = _blocker(session, tenant, product=f"LOW-{index}", field="OTHER", blocker_type="UNMAPPED", index=index, installation_id=installation.id)
|
||||
low.reason_code = f"OTHER_{index}"
|
||||
low.last_seen_at = NOW - timedelta(minutes=index)
|
||||
session.commit()
|
||||
|
||||
attention = dashboard_summary(session, now=NOW)["attention_items"]
|
||||
|
||||
assert len(attention) <= 10
|
||||
assert any(item.get("field") == "NCM" for item in attention)
|
||||
|
||||
|
||||
def test_dashboard_query_count_stays_bounded_with_many_blockers():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1, version=settings.agent_server_version)
|
||||
for index in range(130):
|
||||
_blocker(session, tenant, product=f"P-{index:03}", field="NCM", index=index, installation_id=installation.id)
|
||||
session.commit()
|
||||
statements = []
|
||||
|
||||
def count_statement(conn, cursor, statement, parameters, context, executemany):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(session.bind, "before_cursor_execute", count_statement)
|
||||
try:
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
finally:
|
||||
event.remove(session.bind, "before_cursor_execute", count_statement)
|
||||
|
||||
assert summary["open_blockers"] == 130
|
||||
assert len(statements) <= 18
|
||||
assert not any("SELECT" in sql.upper() and "LIMIT 130" in sql.upper() for sql in statements)
|
||||
|
||||
|
||||
def test_snapshot_coverage_exposes_unclassified_residual_and_overcount():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, agent, installation, snapshot = _installation(session, 1, coverage=80, blocked=1)
|
||||
snapshot.total_products = 100
|
||||
snapshot.fiscally_complete = 96
|
||||
snapshot.safe_blocked = 1
|
||||
snapshot.failed_products = 1
|
||||
session.commit()
|
||||
|
||||
item = list_installations(session, page_size=5)["items"][0]
|
||||
assert item["unclassified_products"] == 2
|
||||
assert item["coverage_overcount_products"] == 0
|
||||
assert item["coverage_invariant_pass"] is True
|
||||
assert item["total_products"] == item["fiscally_complete"] + item["safe_blocked"] + item["failed_products"] + item["unclassified_products"]
|
||||
|
||||
snapshot.fiscally_complete = 99
|
||||
session.commit()
|
||||
inconsistent = list_installations(session, page_size=5)["items"][0]
|
||||
assert inconsistent["unclassified_products"] == 0
|
||||
assert inconsistent["coverage_overcount_products"] == 1
|
||||
assert inconsistent["coverage_invariant_pass"] is False
|
||||
|
||||
|
||||
def test_dashboard_coverage_totals_reconcile_and_keep_registry_metric_separate():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, _, installation, snapshot = _installation(session, 1, coverage=96, blocked=1)
|
||||
snapshot.total_products = 100
|
||||
snapshot.fiscally_complete = 96
|
||||
snapshot.safe_blocked = 1
|
||||
snapshot.failed_products = 1
|
||||
_blocker(session, tenant, product="P-OPEN", field="CFOP", index=91, installation_id=installation.id)
|
||||
session.commit()
|
||||
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
|
||||
assert summary["total_products"] == 100
|
||||
assert summary["fiscally_complete"] == 96
|
||||
assert summary["safe_blocked"] == 1
|
||||
assert summary["failed_products"] == 1
|
||||
assert summary["unclassified_products"] == 2
|
||||
assert summary["coverage_overcount_products"] == 0
|
||||
assert summary["coverage_invariant_pass"] is True
|
||||
assert summary["safe_blocked_products"] == 1
|
||||
assert summary["total_products"] == summary["fiscally_complete"] + summary["safe_blocked"] + summary["failed_products"] + summary["unclassified_products"]
|
||||
|
||||
|
||||
def test_dashboard_marks_inconsistent_snapshot_partition_instead_of_clamping():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
_, _, _, snapshot = _installation(session, 1, coverage=100, blocked=2)
|
||||
snapshot.total_products = 10
|
||||
snapshot.fiscally_complete = 9
|
||||
snapshot.safe_blocked = 2
|
||||
snapshot.failed_products = 0
|
||||
session.commit()
|
||||
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
|
||||
assert summary["coverage_overcount_products"] == 1
|
||||
assert summary["coverage_invariant_pass"] is False
|
||||
|
||||
|
||||
def test_attention_items_are_ranked_and_capped_at_ten():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenants = []
|
||||
for index, health in enumerate(["FAILED"] * 2 + ["DEGRADED"] * 2 + ["OFFLINE"] * 2 + ["UNKNOWN"] * 2 + ["ONLINE"] * 4, 1):
|
||||
tenant = _installation(session, index, health=health, version="0.9.0" if index >= 9 else "1.0.0rc1")[0]
|
||||
tenants.append(tenant)
|
||||
for index, tenant in enumerate(tenants[8:10], 20):
|
||||
_blocker(session, tenant, product=f"P{index}", index=index)
|
||||
session.commit()
|
||||
|
||||
items = dashboard_summary(session, now=NOW)["attention_items"]
|
||||
|
||||
assert len(items) <= 10
|
||||
assert [item["technical_health"] for item in items[:8]] == ["FAILED"] * 2 + ["DEGRADED"] * 2 + ["OFFLINE"] * 2 + ["UNKNOWN"] * 2
|
||||
assert all({"client", "display_name", "hostname", "problem", "timestamp", "age", "link", "technical_health"} <= item.keys() for item in items)
|
||||
|
||||
|
||||
def test_installations_expose_separate_fiscal_and_technical_status_and_worst_sort():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
_installation(session, 1, coverage=90, blocked=0)
|
||||
_installation(session, 2, coverage=40, blocked=1, version="0.9.0")
|
||||
session.commit()
|
||||
|
||||
result = list_installations(session, sort="worst_coverage", page_size=10, now=NOW)
|
||||
item = result["items"][0]
|
||||
|
||||
assert item["display_name"] == "Installation 2"
|
||||
assert item["fiscal_completion_percent"] == 40
|
||||
assert item["technical_health"] == "ONLINE"
|
||||
assert item["fiscal_status"] == "BLOCKED"
|
||||
assert item["version_drift"] == "OUTDATED"
|
||||
assert item["technical_ids"]["agent_id"] == item["agent_id"]
|
||||
|
||||
|
||||
def test_jobs_join_operational_identity_and_format_duration():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, agent, installation, _ = _installation(session, 1)
|
||||
session.add(Job(agent_id=agent.id, kind="REPROCESS_ALL", status="FAILED", created_at=NOW, started_at=NOW, finished_at=NOW + timedelta(seconds=194), error="failure"))
|
||||
session.commit()
|
||||
|
||||
result = list_jobs(session, client="Client 1", search="server-1", page_size=10)
|
||||
item = result["items"][0]
|
||||
|
||||
assert item["client"] == tenant.name
|
||||
assert item["display_name"] == installation.display_name
|
||||
assert item["hostname"] == installation.hostname
|
||||
assert item["job_id"]
|
||||
assert item["duration"] == "3 min 14 s"
|
||||
assert item["error"] == "failure"
|
||||
|
||||
|
||||
def test_blockers_filter_with_priority_age_and_stable_pagination():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1)
|
||||
critical = _blocker(session, tenant, product="P1", field="", blocker_type="TECHNICAL_FAILURE", index=1, installation_id=installation.id)
|
||||
_blocker(session, tenant, product="P2", field="CFOP", index=2, installation_id=installation.id)
|
||||
session.commit()
|
||||
|
||||
result = list_blockers(session, client="Client 1", installation=installation.display_name, status="OPEN", search="P1", page=1, page_size=1)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["items"][0]["id"] == critical.id
|
||||
assert result["items"][0]["priority"] == "CRITICAL"
|
||||
assert result["items"][0]["age"].startswith("há ")
|
||||
assert result["items"][0]["installation_id"] == installation.installation_id
|
||||
|
||||
|
||||
def test_blocker_detail_contains_context_events_jobs_and_resolution_audit():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, agent, installation, _ = _installation(session, 1)
|
||||
blocker = _blocker(session, tenant, product="P1", index=1, installation_id=installation.id)
|
||||
job = Job(agent_id=agent.id, kind="REPROCESS_ALL", status="FAILED", created_at=NOW, error="failed")
|
||||
session.add(job)
|
||||
session.flush()
|
||||
record_event(session, tenant_id=tenant.id, installation_id=installation.id, agent_id=agent.id, severity="ERROR", event_type="JOB_FAILED", message="job failed", blocker_id=blocker.id, job_id=job.id, occurred_at=NOW)
|
||||
record_audit(session, admin_user_id=None, tenant_id=tenant.id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker.id), context={"reason": "reviewed"})
|
||||
session.commit()
|
||||
|
||||
from central.app.control_plane import blocker_detail
|
||||
|
||||
detail = blocker_detail(session, blocker.id)
|
||||
|
||||
assert detail["client"] == tenant.name
|
||||
assert detail["installation"]["installation_id"] == installation.installation_id
|
||||
assert detail["evidence"]["source"] == "test"
|
||||
assert detail["related_events"][0]["job_id"] == job.id
|
||||
assert detail["related_jobs"][0]["job_id"] == job.id
|
||||
assert detail["resolution_audit"][0]["action"] == "RESOLVE_BLOCKER"
|
||||
|
||||
|
||||
def test_blockers_keep_exact_installation_identity_for_same_tenant():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant = Tenant(name="Shared Client")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
agent_a = Agent(tenant_id=tenant.id, name="agent-a", token_hash="token-a".ljust(64, "x"))
|
||||
agent_b = Agent(tenant_id=tenant.id, name="agent-b", token_hash="token-b".ljust(64, "x"))
|
||||
session.add_all([agent_a, agent_b])
|
||||
session.flush()
|
||||
installation_a = Installation(agent_id=agent_a.id, tenant_id=tenant.id, installation_id="00000000-0000-4000-8000-000000000101", display_name="Store A", hostname="server-a")
|
||||
installation_b = Installation(agent_id=agent_b.id, tenant_id=tenant.id, installation_id="00000000-0000-4000-8000-000000000102", display_name="Store B", hostname="server-b")
|
||||
session.add_all([installation_a, installation_b])
|
||||
session.flush()
|
||||
row_a = upsert_blocker(session, tenant.id, {"company": tenant.name, "product_code": "P-A", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "a"}, installation_id=installation_a.id)
|
||||
row_b = upsert_blocker(session, tenant.id, {"company": tenant.name, "product_code": "P-B", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "b"}, installation_id=installation_b.id)
|
||||
|
||||
result_a = list_blockers(session, tenant_id=tenant.id, installation="Store A")
|
||||
result_b = list_blockers(session, tenant_id=tenant.id, installation="Store B")
|
||||
|
||||
assert result_a["total"] == 1 and result_a["items"][0]["id"] == row_a.id
|
||||
assert result_a["items"][0]["installation_id"] == installation_a.installation_id
|
||||
assert result_b["total"] == 1 and result_b["items"][0]["id"] == row_b.id
|
||||
assert result_b["items"][0]["installation_id"] == installation_b.installation_id
|
||||
|
||||
|
||||
def test_legacy_tenant_blocker_has_no_fabricated_installation_context():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1)
|
||||
row = _blocker(session, tenant, product="LEGACY", index=99)
|
||||
result = list_blockers(session, tenant_id=tenant.id, scope="all")
|
||||
|
||||
assert result["items"][0]["id"] == row.id
|
||||
assert result["items"][0]["installation_id"] is None
|
||||
from central.app.control_plane import blocker_detail
|
||||
|
||||
detail = blocker_detail(session, row.id)
|
||||
assert detail["installation"]["installation_id"] is None
|
||||
assert detail["server"]["hostname"] is None
|
||||
assert installation.hostname == "server-1"
|
||||
|
||||
|
||||
def test_dashboard_uses_sql_aggregates_for_operational_totals():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, _, _, _ = _installation(session, 1)
|
||||
_blocker(session, tenant, product="P1", index=101)
|
||||
statements = []
|
||||
event.listen(session.bind, "before_cursor_execute", lambda conn, cursor, statement, parameters, context, executemany: statements.append(statement))
|
||||
|
||||
dashboard_summary(session, now=NOW)
|
||||
|
||||
assert any("COUNT" in statement.upper() and "BLOCKED_PRODUCTS" in statement.upper() for statement in statements)
|
||||
assert any("DISTINCT" in statement.upper() and "BLOCKED_PRODUCTS" in statement.upper() for statement in statements)
|
||||
|
||||
|
||||
def test_health_filter_is_paginated_in_sql_and_counts_all_matches():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
for index in range(1, 4):
|
||||
_installation(session, index, health="ONLINE")
|
||||
statements = []
|
||||
event.listen(session.bind, "before_cursor_execute", lambda conn, cursor, statement, parameters, context, executemany: statements.append(statement))
|
||||
|
||||
result = list_installations(session, health="ONLINE", page=2, page_size=1, now=NOW)
|
||||
|
||||
assert result["total"] == 3
|
||||
assert len(result["items"]) == 1
|
||||
assert any("LIMIT" in statement.upper() and "INSTALLATIONS" in statement.upper() for statement in statements)
|
||||
|
||||
|
||||
def test_worst_coverage_sort_uses_the_displayed_derived_percentage():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
_, _, first, first_snapshot = _installation(session, 1, coverage=80)
|
||||
_, _, second, second_snapshot = _installation(session, 2, coverage=20)
|
||||
first_snapshot.coverage_percent = 10
|
||||
second_snapshot.coverage_percent = 90
|
||||
session.commit()
|
||||
|
||||
result = list_installations(session, sort="worst_coverage", page_size=10, now=NOW)
|
||||
|
||||
assert result["items"][0]["installation_id"] == second.installation_id
|
||||
assert result["items"][0]["fiscal_completion_percent"] == 20
|
||||
assert first.installation_id != second.installation_id
|
||||
|
||||
|
||||
def test_worst_coverage_sort_cannot_disagree_with_one_decimal_display():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
_, _, first, first_snapshot = _installation(session, 1, coverage=0)
|
||||
_, _, second, second_snapshot = _installation(session, 2, coverage=0)
|
||||
first_snapshot.total_products = 79
|
||||
first_snapshot.fiscally_complete = 5
|
||||
second_snapshot.total_products = 16
|
||||
second_snapshot.fiscally_complete = 1
|
||||
session.commit()
|
||||
|
||||
result = list_installations(session, sort="worst_coverage", page_size=10, now=NOW)
|
||||
displayed = [item["fiscal_completion_percent"] for item in result["items"]]
|
||||
|
||||
assert displayed == sorted(displayed)
|
||||
assert displayed == [6.3, 6.3]
|
||||
assert {item["installation_id"] for item in result["items"]} == {first.installation_id, second.installation_id}
|
||||
|
||||
|
||||
def test_lowercase_required_blocker_field_reaches_attention_candidates():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1)
|
||||
_blocker(session, tenant, product="LOWER", field="cfop", index=120, installation_id=installation.id)
|
||||
|
||||
attention = dashboard_summary(session, now=NOW)["attention_items"]
|
||||
|
||||
assert any(item.get("field") == "CFOP" and item["link"].startswith("/blockers?scope=active") for item in attention)
|
||||
|
||||
|
||||
def test_blocker_breakdown_query_groups_on_same_postgres_expression():
|
||||
from sqlalchemy import select
|
||||
|
||||
query = select(BlockedProduct, Tenant).join(Tenant, Tenant.id == BlockedProduct.tenant_id)
|
||||
sql = str(_blocker_breakdown_query(query).compile(dialect=postgresql.dialect()))
|
||||
|
||||
assert "GROUP BY blocked_products.field" in sql
|
||||
assert "GROUP BY coalesce" not in sql
|
||||
|
||||
|
||||
def test_remaining_technical_enums_have_human_labels():
|
||||
from central.app.panel_presentation import health_reason_label
|
||||
assert blocker_reason_label("INVALID_NCM") == "NCM inválido"
|
||||
assert blocker_reason_label("LEGACY_NCM") == "NCM legado"
|
||||
assert blocker_type_label("BLOCKED_MISSING_EVIDENCE") == "Evidência fiscal ausente"
|
||||
assert health_reason_label("last heartbeat exceeds 300s") == "Sem heartbeat recente"
|
||||
assert health_reason_label("persistent technical failure") == "Falha técnica persistente"
|
||||
assert blocker_reason_label("SOMETHING_NEW") == "Something new" # unknown codes degrade readably, never crash
|
||||
|
||||
|
||||
def test_rtc_status_view_is_discreet_and_optional():
|
||||
from central.app.panel_presentation import rtc_status_view
|
||||
assert rtc_status_view(None) is None and rtc_status_view({"coverage": {}}) is None
|
||||
assert rtc_status_view({"fiscal_context": {"rtc_enabled": False}})["label"] == "Desativada"
|
||||
on = rtc_status_view({"fiscal_context": {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True}})
|
||||
assert on["label"] == "Ativa" and on["enabled"] and on["catalog_valid"]
|
||||
assert blocker_type_label("RTC_CST_MISSING") == "CST IBS/CBS ausente no catálogo" and blocker_type_label("COFINS_RATE_INVALID") == "Alíquota COFINS inválida"
|
||||
assert blocker_type_label("PIS_CST_INCIDENCE_MISMATCH") == "CST PIS incompatível com a incidência"
|
||||
|
||||
|
||||
def test_rtc_status_view_hides_product_pending_when_cclasstrib_is_not_applicable():
|
||||
from central.app.panel_presentation import rtc_status_view
|
||||
|
||||
result = rtc_status_view({"fiscal_context": {
|
||||
"rtc_enabled": True,
|
||||
"rtc_resolution": {"total": 521, "valid_existing": 0, "ready_to_apply": 0, "insufficient": 521},
|
||||
"cclasstrib_applicability": {"state": "NOT_APPLICABLE", "basis": "SIMPLES_CCLASSTRIB_NOT_EFFECTIVE"},
|
||||
}})
|
||||
|
||||
assert result["enabled"] is True
|
||||
assert result["products"]["applicable"] is False
|
||||
assert result["products"]["label"] == "cClassTrib: Não utilizado neste cliente"
|
||||
assert "pendente" not in result["products"].get("headline", "").lower()
|
||||
@@ -0,0 +1,21 @@
|
||||
from central.app.origin_resolution import resolve_origin
|
||||
|
||||
|
||||
def test_origin_requires_strong_supplier_evidence():
|
||||
result = resolve_origin("P1", {"value": "0", "strong_evidence": True, "conflict_count": 0}, {})
|
||||
|
||||
assert result["status"] == "ORIGIN_CONFIRMED"
|
||||
assert result["value"] == "0"
|
||||
|
||||
|
||||
def test_origin_conflict_stays_pending():
|
||||
result = resolve_origin("P1", {"value": None, "strong_evidence": False, "conflict_count": 1}, {})
|
||||
|
||||
assert result["status"] == "ORIGIN_CONFLICT"
|
||||
assert result["value"] is None
|
||||
|
||||
|
||||
def test_origin_does_not_use_cst_digit():
|
||||
result = resolve_origin("P1", {}, {"cst": "040"})
|
||||
|
||||
assert result["status"] == "ORIGIN_NEEDS_EVIDENCE"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""OSCBR backfill: prioritised selection, promotion only on agreement, external conflict, quota (5/min, 50/day, reserve, reset), Cosmos out of the chain."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.enrichment import backfill, history, kb, quota
|
||||
from central.app.enrichment.providers import Oscbr, build_providers
|
||||
from central.app.enrichment.quota import ProviderGateway, QuotaManager
|
||||
from central.app.models import KbProduct, KbPromotionAudit, ProviderUsageDaily
|
||||
from central.tests.test_enrichment_history import run, staging
|
||||
from central.tests.test_product_enrichment_resolver import env, gtin # noqa: F401
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_windows():
|
||||
quota._windows.clear()
|
||||
yield
|
||||
quota._windows.clear()
|
||||
|
||||
|
||||
def _oscbr(ncm_by_gtin, calls):
|
||||
def handler(request):
|
||||
if request.url.path == "/oauth/token":
|
||||
return httpx.Response(200, json={"token": "t"})
|
||||
g = request.url.path.rsplit("/", 1)[1]
|
||||
calls.append(g)
|
||||
if g in ncm_by_gtin:
|
||||
return httpx.Response(200, json={"ean": g, "nome": "PRODUTO X", "marca": "M", "ncm": ncm_by_gtin[g], "cest": "0300700"})
|
||||
return httpx.Response(404, json={})
|
||||
prov = Oscbr(httpx.Client(transport=httpx.MockTransport(handler)), None, "u", "p")
|
||||
prov.provides_ncm = True # simulates a plan/answer that DOES carry NCM: exercises the backfill/promotion logic (the real V3 role is IDENTITY)
|
||||
return prov
|
||||
|
||||
|
||||
def _seed(env, n=6):
|
||||
gs = [gtin(f"78960000{i:04d}") for i in range(n)]
|
||||
for i, g in enumerate(gs):
|
||||
run(env, [staging("A", g, "22021000", years={2023: 30 + i, 2024: 40}, code=str(i), desc=f"PRODUTO TESTE {i} 350ML")])
|
||||
return gs
|
||||
|
||||
|
||||
def test_backfill_promotes_only_on_agreement_and_keeps_corroborated_when_no_ncm(env):
|
||||
gs = _seed(env, 4)
|
||||
prov = _oscbr({gs[0]: "22021000", gs[1]: "19059090"}, [])
|
||||
cur = history.official_sets(env)
|
||||
rep = backfill.run(env, ProviderGateway(env, [prov]), prov, limit=10, reserve_percent=20, is_official=lambda v: v in cur, wait_rate_window=False)
|
||||
by = {r["gtin"]: r for r in rep["records"]}
|
||||
assert by[gs[0]]["kb_status"] == "VERIFIED_KB" and by[gs[1]]["kb_status"] == "CONFLICT" and by[gs[1]]["external_conflict"]
|
||||
assert by[gs[2]]["kb_status"] == "CORROBORATED" and by[gs[2]]["found"] is False
|
||||
assert env.scalars(select(KbPromotionAudit)).all()[0].sources["providers"] == ["PROVIDER:oscbr"]
|
||||
cest = [r for r in by.values() if r["cest"]]
|
||||
assert cest and cest[0]["cest"] == "0300700" # CEST kept as evidence only
|
||||
|
||||
|
||||
def test_backfill_never_selects_verified_or_conflict_or_rechecked(env):
|
||||
gs = _seed(env, 3)
|
||||
p = env.scalar(select(KbProduct).where(KbProduct.gtin == "0" + gs[0]))
|
||||
kb.add_evidence(env, p, "ncm", "19059090", "x", family=kb.CLIENT_FAMILY, client_label="Z", lineage={"docs": 50, "years": {"2024": 25, "2025": 25}})
|
||||
env.commit()
|
||||
cur = history.official_sets(env)
|
||||
sample = backfill.select_sample(env, lambda v: v in cur, 10, "oscbr")
|
||||
assert "0" + gs[0] not in {s["gtin_key"] for s in sample} and len(sample) == 2
|
||||
prov = _oscbr({}, [])
|
||||
backfill.run(env, ProviderGateway(env, [prov]), prov, limit=10, reserve_percent=20, is_official=lambda v: v in cur, wait_rate_window=False)
|
||||
assert backfill.select_sample(env, lambda v: v in cur, 10, "oscbr") == [] # checked once, next_checked_at in the future
|
||||
|
||||
|
||||
def test_oscbr_quota_5_per_minute_50_per_day_reserve_and_reset(env):
|
||||
prov = _oscbr({}, [])
|
||||
gw = ProviderGateway(env, [prov])
|
||||
quota._windows["oscbr"].clear()
|
||||
outs = [gw.lookup(prov, gtin(f"78970000{i:04d}")).state for i in range(7)]
|
||||
assert outs[:5] == ["NOT_FOUND"] * 5 and outs[5:] == ["SKIPPED", "SKIPPED"]
|
||||
assert gw.lookup(prov, gtin("789700009991")).reason == "RATE_WINDOW"
|
||||
usage = env.scalar(select(ProviderUsageDaily).where(ProviderUsageDaily.provider == "oscbr"))
|
||||
assert usage.requests == 5
|
||||
quota._windows["oscbr"].clear()
|
||||
usage.requests = 40
|
||||
assert gw.lookup(prov, gtin("789700009992"), "BACKFILL", 20).reason == "OPERATIONAL_RESERVE"
|
||||
assert gw.lookup(prov, gtin("789700009993"), "OPERATIONAL").state == "NOT_FOUND"
|
||||
quota._windows["oscbr"].clear()
|
||||
usage.requests = 50
|
||||
assert gw.lookup(prov, gtin("789700009994"), "OPERATIONAL").reason == "RATE_LIMITED"
|
||||
quota._windows["oscbr"].clear()
|
||||
tomorrow = QuotaManager(env, now=lambda: datetime.now(timezone.utc) + timedelta(days=1, minutes=1))
|
||||
ok, reason, _ = tomorrow.allow(prov)
|
||||
assert ok and reason is None
|
||||
|
||||
|
||||
def test_cosmos_out_of_chain_and_token_preferred():
|
||||
class S:
|
||||
cosmos_token = "SECRET-C"
|
||||
cosmos_enabled = False
|
||||
oscbr_token = "SECRET-T"
|
||||
oscbr_user = ""
|
||||
oscbr_password = ""
|
||||
kodebar_api_key = ""
|
||||
allow_paid_providers = False
|
||||
names = [p.name for p in build_providers(S())]
|
||||
assert "cosmos" not in names and names[0] == "oscbr" and build_providers(S())[0].configured
|
||||
S.cosmos_enabled = True
|
||||
assert build_providers(S())[0].name == "cosmos"
|
||||
|
||||
|
||||
def test_provider_query_canonical_forms():
|
||||
assert backfill.provider_query("00000078936683") == "78936683" # GTIN-8
|
||||
assert backfill.provider_query("07891000100103") == "7891000100103" # EAN-13
|
||||
assert backfill.provider_query("17891000100100") == "17891000100100" # GTIN-14 with indicator
|
||||
|
||||
|
||||
def test_oscbr_real_role_is_identity_and_uses_the_v3_endpoint():
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(request.url.path)
|
||||
return httpx.Response(200, json={"ean": "7891000100103", "ean_tipo": "EAN13", "nome": "COLA 350ML", "marca": "X", "categoria": "Bebidas", "link_foto": "u"})
|
||||
p = Oscbr(httpx.Client(transport=httpx.MockTransport(handler)), "tok")
|
||||
r = p.lookup_gtin("7891000100103")
|
||||
assert seen == ["/api/v3/gtin/7891000100103"] and r.found and r.title == "COLA 350ML" and r.ncm is None and r.cest is None
|
||||
assert Oscbr.provides_ncm is False
|
||||
legacy = Oscbr(httpx.Client(transport=httpx.MockTransport(lambda q: httpx.Response(200, json={"nome": "FEIJAO", "ncm": 7133399}))), "tok").lookup_gtin("7896116900029")
|
||||
assert legacy.ncm == "07133399" # 7-digit integer restored to the 8-digit NCM
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from central.app.panel_presentation import (
|
||||
format_age,
|
||||
format_duration,
|
||||
format_ui_datetime,
|
||||
health_label,
|
||||
operational_priority,
|
||||
)
|
||||
|
||||
|
||||
def test_ui_times_use_configured_timezone_and_human_age(monkeypatch):
|
||||
monkeypatch.setenv("UI_TIMEZONE", "America/Bahia")
|
||||
value = datetime(2026, 9, 22, 19, 11, tzinfo=timezone.utc)
|
||||
|
||||
assert format_ui_datetime(value) == "22/09/2026 16:11"
|
||||
assert format_age(value, datetime(2026, 9, 22, 19, 19, tzinfo=timezone.utc)) == "há 8 min"
|
||||
|
||||
|
||||
def test_ui_datetime_treats_naive_values_as_utc():
|
||||
value = datetime(2026, 9, 22, 19, 11)
|
||||
|
||||
assert format_ui_datetime(value) == "22/09/2026 16:11"
|
||||
|
||||
|
||||
def test_age_handles_empty_and_future_values():
|
||||
now = datetime(2026, 9, 22, 19, 19, tzinfo=timezone.utc)
|
||||
|
||||
assert format_age(None, now) == "-"
|
||||
assert format_age(now + timedelta(minutes=1), now) == "agora"
|
||||
assert format_age(now - timedelta(hours=2), now) == "há 2 h"
|
||||
|
||||
|
||||
def test_duration_and_priority_are_deterministic():
|
||||
start = datetime(2026, 9, 22, 19, 0, tzinfo=timezone.utc)
|
||||
finish = start + timedelta(seconds=194)
|
||||
|
||||
assert format_duration(None, finish) == "-"
|
||||
assert format_duration(start, None) == "em andamento"
|
||||
assert format_duration(start, finish) == "3 min 14 s"
|
||||
assert operational_priority(SimpleNamespace(blocker_type="TECHNICAL_FAILURE", field="", reason_code="X")) == "CRITICAL"
|
||||
|
||||
|
||||
def test_health_label_maps_known_states_and_preserves_unknown_states():
|
||||
assert health_label("ONLINE") == "Online"
|
||||
assert health_label("DEGRADED") == "Degradado"
|
||||
assert health_label("OFFLINE") == "Offline"
|
||||
assert health_label("FAILED") == "Falha"
|
||||
assert health_label("UNKNOWN") == "Desconhecido"
|
||||
assert health_label("NEW_STATE") == "NEW_STATE"
|
||||
@@ -0,0 +1,8 @@
|
||||
def test_payload_compaction_preserves_candidate_codes_and_measures_reduction():
|
||||
from central.app.ai.payloads import compact_payload, measure_payloads
|
||||
|
||||
payload = {"description": "agua", "debug": "remove", "official_candidates": [{"ncm": "22011000", "text": "x"}, {"ncm": "22011000", "text": "x"}, {"ncm": "22019000", "text": "y"}]}
|
||||
compact = compact_payload(payload)
|
||||
assert [row["ncm"] for row in compact["official_candidates"]] == ["22011000", "22019000"]
|
||||
assert "debug" not in compact
|
||||
assert measure_payloads([payload])["tokens_after"] < measure_payloads([payload])["tokens_before"]
|
||||
@@ -0,0 +1,9 @@
|
||||
def test_pre_zai_state_is_explicit_about_human_and_external_blockers(tmp_path):
|
||||
from scripts.generate_pre_zai_state import build_state
|
||||
|
||||
state = build_state(tmp_path)
|
||||
assert state["DETERMINISTIC_COMPLETE"] is True
|
||||
assert state["COMPANY_CONTEXT_COMPLETE"] is False
|
||||
assert state["FIREBIRD_AI_WRITES"] == 0
|
||||
assert state["READY_FOR_ZAI_GOLD_EVAL"] is True
|
||||
assert state["REMAINING_EXTERNAL_BLOCKERS"] == ["EXTERNAL_AI_CREDENTIAL_OR_BALANCE"]
|
||||
@@ -0,0 +1,16 @@
|
||||
from central.app.product_data_provider import ProviderRecord, cache_key, normalize_record
|
||||
|
||||
|
||||
def test_provider_record_has_stable_gtin_cache_key_and_hash():
|
||||
record = normalize_record("GS1", "v1", "789", {"brand": "Acme", "description": "Produto"})
|
||||
|
||||
assert isinstance(record, ProviderRecord)
|
||||
assert record.raw_hash
|
||||
assert cache_key(record.gtin, record.provider, record.provider_record_version).startswith("789:")
|
||||
|
||||
|
||||
def test_provider_normalization_does_not_claim_fiscal_authority():
|
||||
record = normalize_record("COMMERCIAL", "v1", "789", {"ncm": "12345678"})
|
||||
|
||||
assert record.normalized_data["ncm"] == "12345678"
|
||||
assert record.fiscal_authority == "OBSERVATION_ONLY"
|
||||
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from central.app.product_defaults import cfop_default, cst_status, csosn_default, validate_against_history
|
||||
|
||||
CFOPS = {"5102", "5405", "5656", "1102"}
|
||||
CSOSNS = {"101", "102", "103", "500", "400"}
|
||||
|
||||
|
||||
def P(**kw):
|
||||
return {"CFOP": "5102", "CSOSN": "102", "CST": "040", **kw}
|
||||
|
||||
|
||||
def test_cfop_consistent_non_st_is_resolved_but_never_a_write():
|
||||
r = cfop_default(P(), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CFOPS)
|
||||
assert r["status"] == "CFOP_DEFAULT_RESOLVED" and r["action_class"] == "ALREADY_CORRECT" and not r["write_eligible"]
|
||||
|
||||
|
||||
def test_cfop_st_conflict_is_candidate_review_not_write():
|
||||
r = cfop_default(P(), "EXACT_NCM", "MVA_RESOLVED", CFOPS)
|
||||
assert r["status"] == "CFOP_DEFAULT_CANDIDATE" and r["proposed_value"] == "5405" and r["action_class"] == "REVIEW_REQUIRED" and not r["write_eligible"]
|
||||
|
||||
|
||||
def test_cfop_insufficient_context_never_resolved():
|
||||
assert cfop_default(P(), "EXACT_NCM", "MVA_CANDIDATES", CFOPS)["status"] == "CFOP_OPERATION_CONTEXT_REQUIRED"
|
||||
assert cfop_default(P(CFOP=None), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CFOPS)["status"] == "CFOP_OPERATION_CONTEXT_REQUIRED"
|
||||
assert cfop_default(P(CFOP="6102"), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CFOPS | {"6102"})["status"] == "CFOP_OPERATION_CONTEXT_REQUIRED"
|
||||
assert cfop_default(P(), "LEGACY_NCM", "NO_MVA_MAPPING_FOUND", CFOPS)["status"] == "CFOP_UNRESOLVED"
|
||||
assert cfop_default(P(CFOP="9999"), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CFOPS)["status"] == "CFOP_UNRESOLVED"
|
||||
|
||||
|
||||
def test_csosn_is_never_resolved_and_never_universal_102():
|
||||
for mva in ("MVA_RESOLVED", "MVA_CANDIDATES", "NO_MVA_MAPPING_FOUND"):
|
||||
r = csosn_default(P(), "EXACT_NCM", mva, CSOSNS, "102")
|
||||
assert r["status"] != "CSOSN_DEFAULT_RESOLVED" and not r["write_eligible"]
|
||||
r = csosn_default(P(CSOSN="101"), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CSOSNS, "102")
|
||||
assert r["proposed_value"] is None
|
||||
|
||||
|
||||
def test_csosn_st_subject_flags_conflict_and_requires_retention_context():
|
||||
r = csosn_default(P(), "EXACT_NCM", "MVA_RESOLVED", CSOSNS, "102")
|
||||
assert r["status"] == "CSOSN_OPERATION_CONTEXT_REQUIRED" and r["consistency_flag"] == "ST_SUBJECT_BUT_CSOSN_NOT_500"
|
||||
|
||||
|
||||
def test_history_validates_but_does_not_create_rules():
|
||||
assert validate_against_history("5102", ["5102", "5102"]) == "MATCH"
|
||||
assert validate_against_history("5102", ["5102", "5405"]) == "MISMATCH"
|
||||
assert validate_against_history("5102", []) == "UNVALIDATED"
|
||||
assert validate_against_history(None, ["5102"]) == "UNVALIDATED" # history alone never proposes a value
|
||||
|
||||
|
||||
def test_cst_is_reference_only_under_crt1():
|
||||
r = cst_status(P(), {"40", "00"}, set("012345678"))
|
||||
assert r["status"] == "CST_REFERENCE_ONLY" and r["origin_digit"] == "0" and not r["write_eligible"]
|
||||
assert cst_status(P(CST="99"), {"40"}, set("0"))["status"] == "CST_INVALID_REVIEW"
|
||||
|
||||
|
||||
def test_engine_is_deterministic_idempotent():
|
||||
a = cfop_default(P(), "EXACT_NCM", "MVA_RESOLVED", CFOPS)
|
||||
assert a == cfop_default(P(), "EXACT_NCM", "MVA_RESOLVED", CFOPS)
|
||||
|
||||
|
||||
REC = Path(__file__).resolve().parents[2] / "docs" / "NCM_HISTORICAL_RECONCILIATION_V1.json"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not REC.exists(), reason="reconciliation artifact not generated")
|
||||
def test_reconciliation_accounts_for_every_mismatch_and_finds_no_technical_bug():
|
||||
d = json.loads(REC.read_text(encoding="utf-8"))
|
||||
assert sum(d["classification"].values()) == d["mismatches_total"]
|
||||
assert d["classification"].get("NORMALIZATION_BUG", 0) == 0
|
||||
assert d["trusted_corpus"]["TRUSTED_HISTORICAL_ITEMS"] > 0
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Hub side of the enrichment: feature gates, resolve endpoint, audit, human override, blockers, UI route, waiting-for-configuration."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.enrichment import api as enrich_api
|
||||
from central.app.models import AuditEvent, BlockedProduct, Installation, Job, ProductEnrichment, ProductFieldAudit
|
||||
from central.tests.test_control_panel_pages import _panel_client
|
||||
from central.tests.test_product_enrichment_resolver import COSMOS, Fake, G1, G4, seed
|
||||
from central.tests.test_rtc_security import _become, _post
|
||||
from central.tests.test_zero_touch_hub import H, _client, hb
|
||||
|
||||
|
||||
def setup(monkeypatch, mode="ENABLED"):
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
with Session(engine) as s:
|
||||
seed(s)
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
inst.enrichment_mode = mode
|
||||
s.commit()
|
||||
monkeypatch.setattr(enrich_api, "PROVIDERS_OVERRIDE", [Fake("cosmos", COSMOS(), True), Fake("other", COSMOS(), True)])
|
||||
return client, engine
|
||||
|
||||
|
||||
def resolve(client, code="N1", gtin=G1, desc="REFRIGERANTE COLA LATA 350ML", current=None, ev=None):
|
||||
body = {"product_code": code, "gtin": gtin, "description": desc, "current": current or {}}
|
||||
if ev is not None:
|
||||
body["ev"] = ev
|
||||
return client.post("/v1/products/resolve", json=body, headers=H).json()
|
||||
|
||||
|
||||
def test_disabled_installation_is_not_processed_and_never_calls_providers(monkeypatch):
|
||||
client, engine = setup(monkeypatch, "DISABLED")
|
||||
body = resolve(client)
|
||||
assert body["state"] == "FEATURE_DISABLED" and enrich_api.PROVIDERS_OVERRIDE[0].calls == 0
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(ProductEnrichment)).all() == []
|
||||
|
||||
|
||||
def test_enabled_resolve_returns_field_decisions_and_records_state(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
body = resolve(client)
|
||||
assert body["state"] == "MULTI_SOURCE_MATCH" and body["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE"
|
||||
assert body["mode"] == "ENABLED" and "COD_ORIGEM_MERCADORIA" not in body["fields"]
|
||||
with Session(engine) as s:
|
||||
row = s.scalars(select(ProductEnrichment)).one()
|
||||
assert row.product_code == "N1" and row.status == "RESOLVED" and row.gtin.endswith(G1)
|
||||
|
||||
|
||||
def test_applied_report_is_audited_and_human_override_is_final(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
resolve(client)
|
||||
fields = [{"field": "CLASSIFICACAO_FISCAL", "outcome": "APPLIED", "old": None, "new": "22021000", "rule": "NCM_TWO_INDEPENDENT_SOURCES", "kb_status": "VERIFIED_KB", "evidence": ["PROVIDER:cosmos"]}]
|
||||
assert client.post("/v1/products/enrichment/applied", json={"product_code": "N1", "fields": fields, "dataset_versions": {"NCM": {"sha256": "a"}}}, headers=H).json()["ok"]
|
||||
client.post("/v1/products/enrichment/applied", json={"product_code": "N1", "fields": [{"field": "CEST", "outcome": "HUMAN_OVERRIDDEN", "old": "0300700", "new": None}]}, headers=H)
|
||||
again = resolve(client)
|
||||
assert again["fields"]["CEST"]["status"] == "HUMAN_OVERRIDDEN"
|
||||
with Session(engine) as s:
|
||||
audit = s.scalars(select(ProductFieldAudit).order_by(ProductFieldAudit.id)).all()
|
||||
assert [(a.field, a.outcome) for a in audit] == [("CLASSIFICACAO_FISCAL", "APPLIED"), ("CEST", "HUMAN_OVERRIDDEN")]
|
||||
assert audit[0].dataset_versions == {"NCM": {"sha256": "a"}} and audit[0].old_value is None
|
||||
assert client.post("/v1/products/enrichment/applied", json={"product_code": "UNKNOWN", "fields": []}, headers=H).status_code == 404
|
||||
|
||||
|
||||
def test_resolve_wires_field_contract_fiscal_completion_fields_alongside_ncm(monkeypatch):
|
||||
"""field_contract.resolve_product was previously only exercised by its own unit tests. This proves the LIVE /v1/products/resolve endpoint
|
||||
now calls it too: once the NCM auto-fills, CFOP/CST/CSOSN/PIS-COFINS/... decisions ride along in the same response - no second engine, no
|
||||
separate round trip - using company_uf/company_crt already auto-discovered from the Agent's heartbeat (Installation.company_uf/company_crt)."""
|
||||
from central.app.enrichment import fiscal_completion as fcomp
|
||||
from central.app.models import FiscalContextEntry
|
||||
client, engine = setup(monkeypatch)
|
||||
with Session(engine) as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
inst.company_uf, inst.company_crt, inst.company_code = "BA", "1", "000001"
|
||||
s.commit()
|
||||
tenant_id = inst.tenant_id
|
||||
body = resolve(client)
|
||||
assert body["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE"
|
||||
for field in fcomp.FISCAL_COMPLETION_FIELDS: # every fiscal-completion field gets an explicit decision, never silently missing
|
||||
assert field in body["fields"], field
|
||||
assert body["fields"]["CSOSN"]["status"] == "NEEDS_REVIEW" and body["fields"]["CSOSN"]["missing_key"] == "icms_treatment" # 0 FiscalContextEntry rows: correctly blocked, not guessed
|
||||
with Session(engine) as s:
|
||||
s.add(FiscalContextEntry(tenant_id=tenant_id, company_key="000001", scope="COMPANY", scope_key="", key="icms_treatment", value="TAXED", source="OPERATOR"))
|
||||
s.commit()
|
||||
again = resolve(client, code="N9")
|
||||
assert again["fields"]["CSOSN"]["status"] == "AUTO_FILL_SAFE" and again["fields"]["CSOSN"]["value"] == "102" # same operator answer, field independently resolved once context exists
|
||||
|
||||
|
||||
def test_resolve_accepts_agents_targeted_product_evidence_end_to_end(monkeypatch):
|
||||
"""The Agent's targeted, product_code-scoped recipes/composed/purchases/stock evidence (firebird_reader.read_product_evidence) rides the
|
||||
resolve payload as `ev` and reaches field_contract.infer_role - proven end-to-end through the real HTTP route, not just the fiscal_completion
|
||||
unit tests. Recipe evidence alone proves OWN_PRODUCTION with zero operator questions."""
|
||||
client, engine = setup(monkeypatch)
|
||||
with Session(engine) as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
inst.company_uf, inst.company_crt, inst.company_code = "BA", "1", "000001"
|
||||
s.commit()
|
||||
body = resolve(client, code="N10", ev={"recipes": ["N10"], "composed": [], "purchases": {}, "stock_entry": {}, "stock_in": {}})
|
||||
assert body["fields"]["CFOP"]["status"] == "NEEDS_REVIEW" and body["fields"]["CFOP"].get("missing_key") != "product_role"
|
||||
|
||||
|
||||
def test_unknown_gtin_creates_a_human_blocker_in_portuguese(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
monkeypatch.setattr(enrich_api, "PROVIDERS_OVERRIDE", [Fake("cosmos", {}, True)])
|
||||
assert resolve(client, "N2", G4, "PRODUTO XYZ")["state"] == "NOT_FOUND"
|
||||
with Session(engine) as s:
|
||||
b = s.scalars(select(BlockedProduct).where(BlockedProduct.field == "ENRICHMENT")).one()
|
||||
assert b.reason_code == "NOT_FOUND" and "não encontrado" in b.evidence_summary["message"]
|
||||
|
||||
|
||||
def test_authorization_endpoint_and_job_gating(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
assert client.get("/v1/products/enrichment/authorization", headers=H).json()["enrichment_mode"] == "ENABLED"
|
||||
with Session(engine) as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
inst.features_configured = False
|
||||
s.commit()
|
||||
assert client.get("/v1/jobs/next", headers=H).json() is None # WAITING_FEATURE_CONFIGURATION: nothing is handed out
|
||||
body = client.get("/v1/agents/me/authorizations", headers=H).json()
|
||||
assert body["features"]["features_configured"] is False
|
||||
|
||||
|
||||
def test_features_route_is_admin_only_csrf_and_audited():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "PADRAO")
|
||||
assert _post(client, f"/agents/{agent_id}/features", enrichment_mode="DRY_RUN", corrector_enabled="1").status_code == 403
|
||||
_become(client, "ADMIN")
|
||||
assert _post(client, f"/agents/{agent_id}/features", enrichment_mode="ENABLED", corrector_enabled="1").status_code == 422 # needs typed confirmation
|
||||
assert _post(client, f"/agents/{agent_id}/features", enrichment_mode="DRY_RUN", corrector_enabled="1").status_code == 303
|
||||
assert client.post(f"/agents/{agent_id}/features", data={"csrf_token": "bad", "enrichment_mode": "DRY_RUN"}, follow_redirects=False).status_code in (400, 403)
|
||||
with client._session_factory() as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
assert inst.enrichment_mode == "DRY_RUN" and inst.features_configured and inst.corrector_enabled
|
||||
assert "INSTALLATION_FEATURES" in [e.action for e in s.scalars(select(AuditEvent))]
|
||||
page = client.get(f"/agents/{agent_id}")
|
||||
assert "Funcionalidades" in page.text and "Enriquecimento" in page.text
|
||||
|
||||
|
||||
def test_dashboard_shows_sources_without_tokens_and_official_freshness(monkeypatch):
|
||||
from central.app.enrichment import observability, providers
|
||||
monkeypatch.setattr(observability, "build_providers", lambda _s: providers.build_providers(type("S", (), {"oscbr_token": "SEGREDO-NAO-VAZAR", "allow_paid_providers": False})()))
|
||||
client, agent_id, _ = _panel_client()
|
||||
page = client.get("/").text
|
||||
assert "Fontes de produtos" in page and "oscbr" in page and "cosmos" not in page and "configurado: sim" in page and "upcitemdb" in page and "openfoodfacts" in page
|
||||
assert "SEGREDO-NAO-VAZAR" not in page and "Fontes oficiais" in page and "NCM (Siscomex)" in page
|
||||
|
||||
|
||||
def _second_agent(engine, mode="ENABLED", corrector=True):
|
||||
from central.app.auth import token_hash
|
||||
from central.app.models import Agent, Tenant
|
||||
with Session(engine) as s:
|
||||
first = s.scalars(select(Installation)).first()
|
||||
agent = Agent(tenant_id=first.tenant_id, name="second", token_hash=token_hash("second-token"), enabled=True)
|
||||
s.add(agent)
|
||||
s.flush()
|
||||
s.add(Installation(agent_id=agent.id, tenant_id=first.tenant_id, installation_id="00000000-0000-4000-8000-0000000000bb", display_name="B", features_configured=True, corrector_enabled=corrector, enrichment_mode=mode))
|
||||
s.commit()
|
||||
return {"Authorization": "Bearer second-token"}
|
||||
|
||||
|
||||
def test_same_product_in_another_installation_costs_zero_extra_provider_requests(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
h2 = _second_agent(engine)
|
||||
providers = enrich_api.PROVIDERS_OVERRIDE
|
||||
first = resolve(client)
|
||||
calls_after_first = [p.calls for p in providers]
|
||||
second = client.post("/v1/products/resolve", json={"product_code": "X9", "gtin": G1, "description": "REFRIGERANTE COLA LATA 350ML", "current": {}}, headers=h2).json()
|
||||
assert [p.calls for p in providers] == calls_after_first # other installation: served by KB, zero external calls
|
||||
assert second["state"] == "EXACT_GTIN_VERIFIED" and second["fields"]["CLASSIFICACAO_FISCAL"]["value"] == first["fields"]["CLASSIFICACAO_FISCAL"]["value"]
|
||||
with Session(engine) as s:
|
||||
assert {r.installation_id for r in s.scalars(select(ProductEnrichment))} == {1, 2}
|
||||
|
||||
|
||||
def test_installations_are_isolated_by_their_own_feature_flags(monkeypatch):
|
||||
client, engine = setup(monkeypatch, "DISABLED")
|
||||
h2 = _second_agent(engine, "ENABLED")
|
||||
assert resolve(client)["state"] == "FEATURE_DISABLED"
|
||||
body = client.post("/v1/products/resolve", json={"product_code": "N1", "gtin": G1, "description": "REFRIGERANTE COLA LATA 350ML", "current": {}}, headers=h2).json()
|
||||
assert body["state"] != "FEATURE_DISABLED" and body["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE"
|
||||
|
||||
|
||||
def test_quota_exhausted_gives_pending_state_and_retry_time_through_the_api(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
monkeypatch.setattr(enrich_api, "PROVIDERS_OVERRIDE", [Fake("cosmos", COSMOS(), True, daily_limit=1)])
|
||||
resolve(client, "A1", G1)
|
||||
body = resolve(client, "A2", G4, "OUTRO PRODUTO")
|
||||
assert body["state"] == "PENDING_PROVIDER_QUOTA" and body["retry_at"]
|
||||
with Session(engine) as s:
|
||||
row = s.scalars(select(ProductEnrichment).where(ProductEnrichment.product_code == "A2")).one()
|
||||
assert row.status == "PENDING" and row.next_attempt_at is not None
|
||||
assert s.scalars(select(BlockedProduct).where(BlockedProduct.reason_code == "PENDING_PROVIDER_QUOTA")).one().field == "ENRICHMENT"
|
||||
|
||||
|
||||
def test_corrector_switch_off_stops_jobs_but_not_enrichment(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
with Session(engine) as s:
|
||||
s.scalars(select(Installation)).one().corrector_enabled = False
|
||||
s.commit()
|
||||
assert client.get("/v1/jobs/next", headers=H).json() is None
|
||||
assert resolve(client)["state"] != "FEATURE_DISABLED"
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("corrector,mode,configured,jobs,enrich", [
|
||||
(True, "DISABLED", True, True, False), # Corrector ON / Enrichment OFF
|
||||
(False, "DRY_RUN", True, False, True), # Corrector OFF / DRY_RUN
|
||||
(False, "ENABLED", True, False, True), # Corrector OFF / ENABLED
|
||||
(True, "ENABLED", True, True, True), # both ON
|
||||
(False, "DISABLED", False, False, False), # no features -> WAITING_FEATURE_CONFIGURATION
|
||||
])
|
||||
def test_feature_mode_matrix(monkeypatch, corrector, mode, configured, jobs, enrich):
|
||||
client, engine = setup(monkeypatch, mode)
|
||||
with Session(engine) as s:
|
||||
i = s.scalars(select(Installation)).one()
|
||||
i.corrector_enabled, i.features_configured = corrector, configured
|
||||
from central.app.jobs import create_job
|
||||
from central.app.models import Agent
|
||||
create_job(s, s.scalars(select(Agent)).one(), "SYNC_INCREMENTAL", {})
|
||||
s.commit()
|
||||
assert (client.get("/v1/jobs/next", headers=H).json() is not None) == jobs
|
||||
assert (resolve(client)["state"] != "FEATURE_DISABLED") == enrich
|
||||
feats = client.get("/v1/agents/me/authorizations", headers=H).json()["features"]
|
||||
assert feats["features_configured"] == configured
|
||||
|
||||
|
||||
def test_provider_panel_shows_every_state_and_column(monkeypatch):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from central.app.models import ProviderState, ProviderUsageDaily
|
||||
client, agent_id, _ = _panel_client()
|
||||
now = datetime.now(timezone.utc)
|
||||
with client._session_factory() as s:
|
||||
s.add(ProviderState(provider="upcitemdb", configured=True, status="RATE_LIMITED", quota_kind="REMOTE_QUOTA", daily_limit=100, remaining=0, reset_at=now + timedelta(hours=3), last_success_at=now, last_error="RATE_LIMITED: HTTP 429", last_latency_ms=140))
|
||||
s.add(ProviderState(provider="openfoodfacts", configured=True, status="DEGRADED", last_latency_ms=655))
|
||||
s.add(ProviderState(provider="openproductsfacts", configured=True, status="AUTH_ERROR"))
|
||||
s.add(ProviderState(provider="openbeautyfacts", configured=True, status="OFFLINE"))
|
||||
s.add(ProviderUsageDaily(provider="upcitemdb", day=now.strftime("%Y-%m-%d"), requests=100, cache_hits=30, cache_misses=70))
|
||||
s.commit()
|
||||
page = client.get("/").text
|
||||
for label in ("Limite atingido", "Instável", "Credencial recusada", "Indisponível", "Não configurado", "100 / 100", "restam 0", "renova", "último sucesso", "último erro", "140 ms", "cache 30%"):
|
||||
assert label in page, label
|
||||
|
||||
|
||||
def test_dashboard_kb_panel_and_provider_daily_columns(monkeypatch):
|
||||
from central.app.enrichment import kb as kbm
|
||||
from central.app.models import KbProduct
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as s:
|
||||
p = kbm.upsert_product(s, "07891000100103", "COLA LATA 350ML")
|
||||
kbm.add_evidence(s, p, "ncm", "22021000", "c", family=kbm.CLIENT_FAMILY, client_label="A", lineage={"docs": 900, "years": {"2024": 900}})
|
||||
kbm.add_evidence(s, p, "ncm", "19059090", "c", family=kbm.CLIENT_FAMILY, client_label="B", lineage={"docs": 30, "years": {"2025": 30}})
|
||||
kbm.evaluate(s, p, "ncm", lambda v: True)
|
||||
s.commit()
|
||||
page = client.get("/").text
|
||||
assert "Base de conhecimento de produtos" in page and "NCMs divergentes" in page and "22021000" in page and "19059090" in page
|
||||
assert "achados" in page and "próximo reset" in page or "hoje:" in page
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Product enrichment resolver: 12 resolution scenarios, quota/cache/circuit, provider fixtures. No internet."""
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.enrichment import kb
|
||||
from central.app.enrichment.gtin import check_digit_ok
|
||||
from central.app.enrichment.providers import Kodebar, Oscbr, Cosmos, OpenFoodFacts, Provider, ProviderError, ProviderProduct, UpcItemDb, build_providers
|
||||
from central.app.enrichment.quota import ProviderGateway
|
||||
from central.app.enrichment.resolver import resolve
|
||||
from central.app.models import (Base, CestEntry, CestNcmEntry, Dataset, GtinLookupCache, KbKnowledge, NcmEntry, ProviderUsageDaily, TipiEntry)
|
||||
|
||||
|
||||
def gtin(body12: str) -> str:
|
||||
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body12)))
|
||||
return body12 + str((10 - total % 10) % 10)
|
||||
|
||||
|
||||
G1, G2, G3, G4 = gtin("789100010010"), gtin("789100010020"), gtin("789100010030"), gtin("789100010040")
|
||||
|
||||
|
||||
class Fake(Provider):
|
||||
def __init__(self, name, data=None, ncm_provider=False, error=None, daily_limit=None):
|
||||
super().__init__(client=httpx.Client())
|
||||
self.name, self.data, self.provides_ncm, self.error, self.calls = name, data or {}, ncm_provider, error, 0
|
||||
self.daily_limit = daily_limit
|
||||
|
||||
def lookup_gtin(self, g):
|
||||
self.calls += 1
|
||||
if self.error:
|
||||
raise self.error
|
||||
d = self.data.get(g) or self.data.get(g.lstrip('0'))
|
||||
return ProviderProduct(self.name, g, True, **d) if d else ProviderProduct(self.name, g, False)
|
||||
|
||||
|
||||
def seed(session):
|
||||
ncm = Dataset(kind="NCM", provider="Siscomex", sha256="a" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=3)
|
||||
session.add(ncm)
|
||||
session.flush()
|
||||
for code in ("22021000", "19059090", "21069090"):
|
||||
session.add(NcmEntry(dataset_id=ncm.id, code=code, description="x", valid_from="01/04/2022", valid_to="31/12/9999"))
|
||||
session.add(NcmEntry(dataset_id=ncm.id, code="11111111", description="expired", valid_from="01/01/2000", valid_to="31/12/2001"))
|
||||
tipi = Dataset(kind="TIPI_IPI", provider="RFB", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
cest = Dataset(kind="CEST", provider="CONFAZ", sha256="c" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add_all([tipi, cest])
|
||||
session.flush()
|
||||
session.add(TipiEntry(dataset_id=tipi.id, ncm="22021000", ex=None, description="x", ipi_rate=0.0, ipi_rate_text="0", source_row=1))
|
||||
entry = CestEntry(dataset_id=cest.id, code="03.007.00", description="REFRIGERANTE COLA LATA 350ML refrigerantes", annex="I")
|
||||
session.add(entry)
|
||||
session.flush()
|
||||
session.add(CestNcmEntry(cest_entry_id=entry.id, ncm_code="22021000"))
|
||||
session.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
seed(session)
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def run(session, providers, g, desc, current=None, **kw):
|
||||
return resolve(session, ProviderGateway(session, providers), gtin_raw=g, description=desc, current=current or {}, **kw)
|
||||
|
||||
|
||||
COSMOS = lambda ncm="22021000": {G1: dict(title="REFRIGERANTE COLA LATA 350ML", brand="X", ncm=ncm)}
|
||||
UPC = {G1: dict(title="Refrigerante Cola Lata 350ml", brand="X")}
|
||||
|
||||
|
||||
def test_1_gtin_verified_kb_no_provider_call(env):
|
||||
p = kb.upsert_product(env, kb.gtin_fingerprint("0" + G1), "COLA", brand=None) if False else kb.upsert_product(env, "0" + G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "provider:cosmos", family="PROVIDER:cosmos")
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "provider:other", family="PROVIDER:other")
|
||||
env.commit()
|
||||
fake = Fake("cosmos", COSMOS(), True)
|
||||
r = run(env, [fake], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert r["state"] == "EXACT_GTIN_VERIFIED" and fake.calls == 0
|
||||
f = r["fields"]
|
||||
assert f["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE" and f["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
|
||||
assert f["CEST"]["value"] == "0300700" and f["IPI"]["value"] == "0.0"
|
||||
|
||||
|
||||
def test_2_two_independent_providers_agree_multi_source(env):
|
||||
a, b = Fake("cosmos", COSMOS(), True), Fake("other", {G1: dict(title="REFRIGERANTE COLA LATA 350ML", ncm="22021000")}, True)
|
||||
r = run(env, [a, b], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert r["state"] == "MULTI_SOURCE_MATCH"
|
||||
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE"
|
||||
# promoted: a second installation is served from the KB alone
|
||||
a2 = Fake("cosmos", COSMOS(), True)
|
||||
r2 = run(env, [a2], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert r2["state"] == "EXACT_GTIN_VERIFIED" and a2.calls == 0
|
||||
|
||||
|
||||
def test_3_single_source_ncm_is_suggestion_only(env):
|
||||
r = run(env, [Fake("cosmos", COSMOS(), True)], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "SUGGESTION_ONLY"
|
||||
assert r["fields"]["CEST"]["status"] == "INSUFFICIENT_EVIDENCE" and r["fields"]["IPI"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def test_4_identity_only_sources_never_give_ncm(env):
|
||||
r = run(env, [Fake("upcitemdb", UPC), Fake("openfoodfacts", UPC)], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert r["state"] == "MULTI_SOURCE_MATCH"
|
||||
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def test_5_conflicting_ncm_zero_write(env):
|
||||
a, b = Fake("cosmos", COSMOS("22021000"), True), Fake("other", COSMOS("19059090"), True)
|
||||
r = run(env, [a, b], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert r["state"] == "CONFLICT"
|
||||
assert all(v["status"] != "AUTO_FILL_SAFE" for v in r["fields"].values())
|
||||
|
||||
|
||||
def test_6_ncm_not_in_official_dataset_rejected(env):
|
||||
a, b = Fake("cosmos", COSMOS("99999999"), True), Fake("other", COSMOS("99999999"), True)
|
||||
r = run(env, [a, b], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||
a, b = Fake("cosmos", COSMOS("11111111"), True), Fake("other", COSMOS("11111111"), True) # expired NCM is not vigente
|
||||
assert run(env, [a, b], gtin("789100010050"), "X")["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def test_7_invalid_gtin_never_calls_providers(env):
|
||||
fake = Fake("cosmos", COSMOS(), True)
|
||||
r = run(env, [fake], G1[:-1] + str((int(G1[-1]) + 1) % 10), "PIPOCA")
|
||||
assert r["gtin_status"] == "INVALID_GTIN" and r["state"] == "INVALID_GTIN" and fake.calls == 0
|
||||
|
||||
|
||||
def test_8_gtin_not_found_anywhere(env):
|
||||
r = run(env, [Fake("cosmos", {}, True), Fake("upcitemdb")], G2, "PRODUTO QUALQUER")
|
||||
assert r["state"] == "NOT_FOUND" and r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def test_9_name_only_exact_verified_fingerprint(env):
|
||||
p = kb.upsert_product(env, "0" + G1, "REFRIGERANTE COLA ORIGINAL LATA 350 ML")
|
||||
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "x", family=fam)
|
||||
env.commit()
|
||||
r = run(env, [Fake("cosmos")], "", "REFRIGERANTE COLA ORIGINAL LATA 350ML")
|
||||
assert r["state"] == "EXACT_KB_NAME_VERIFIED" and r["fields"]["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
|
||||
|
||||
|
||||
def test_10_generic_name_never_fills(env):
|
||||
p = kb.upsert_product(env, "0" + G1, "REFRIGERANTE")
|
||||
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "x", family=fam)
|
||||
env.commit()
|
||||
r = run(env, [], "", "REFRIGERANTE")
|
||||
assert r["state"] == "AMBIGUOUS" and all(v["status"] != "AUTO_FILL_SAFE" for v in r["fields"].values())
|
||||
|
||||
|
||||
def test_11_name_matching_different_ncms_is_conflict(env):
|
||||
for g, ncm in ((G1, "22021000"), (G3, "21069090")):
|
||||
p = kb.upsert_product(env, "0" + g, "SUCO NATURAL LARANJA GARRAFA 1L")
|
||||
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||
kb.add_evidence(env, p, "ncm", ncm, "x", family=fam)
|
||||
env.commit()
|
||||
r = run(env, [], "", "SUCO NATURAL LARANJA GARRAFA 1L")
|
||||
assert r["state"] == "CONFLICT" and all(v["status"] == "CONFLICT" for v in r["fields"].values())
|
||||
|
||||
|
||||
def test_12_already_filled_fields_are_never_touched(env):
|
||||
p = kb.upsert_product(env, "0" + G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "x", family=fam)
|
||||
env.commit()
|
||||
r = run(env, [], G1, "REFRIGERANTE COLA LATA 350ML", current={"CLASSIFICACAO_FISCAL": "19059090", "CEST": "", "IPI": None})
|
||||
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "ALREADY_SET"
|
||||
|
||||
|
||||
def test_client_consensus_needs_two_distinct_clients(env):
|
||||
p = kb.upsert_product(env, "0" + G1, "X")
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "client", family=kb.CLIENT_FAMILY, client_label="A")
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "client", family=kb.CLIENT_FAMILY, client_label="A")
|
||||
assert kb.evaluate(env, p, "ncm", lambda v: True)["status"] == kb.OBSERVED
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "client", family=kb.CLIENT_FAMILY, client_label="B")
|
||||
assert kb.evaluate(env, p, "ncm", lambda v: True)["status"] == kb.CORROBORATED # consensus alone is one family, never authority
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "provider", family="PROVIDER:cosmos")
|
||||
assert kb.evaluate(env, p, "ncm", lambda v: True)["status"] == kb.VERIFIED_KB
|
||||
|
||||
|
||||
def test_cache_spares_quota_across_installations(env):
|
||||
fake = Fake("upcitemdb", UPC)
|
||||
for _ in range(5):
|
||||
run(env, [fake], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert fake.calls == 1
|
||||
usage = env.scalar(select(ProviderUsageDaily).where(ProviderUsageDaily.provider == "upcitemdb"))
|
||||
assert usage.requests == 1 and usage.cache_hits == 4
|
||||
|
||||
|
||||
def test_negative_cache(env):
|
||||
fake = Fake("upcitemdb")
|
||||
for _ in range(3):
|
||||
run(env, [fake], G2, "PRODUTO X")
|
||||
assert fake.calls == 1
|
||||
|
||||
|
||||
def test_quota_exhausted_pending_with_retry(env):
|
||||
fake = Fake("cosmos", COSMOS(), True, daily_limit=1)
|
||||
run(env, [fake], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
r = run(env, [fake], G3, "OUTRO PRODUTO")
|
||||
assert r["state"] == "PENDING_PROVIDER_QUOTA" and r["retry_at"] is not None and fake.calls == 1
|
||||
|
||||
|
||||
def test_circuit_breaker_and_timeout_is_not_a_crash(env):
|
||||
fake = Fake("upcitemdb", error=ProviderError("TIMEOUT", "x"))
|
||||
states = [run(env, [fake], gtin(f"78910001{i:04d}"), "P")["state"] for i in range(6)]
|
||||
assert fake.calls == 3 # circuit opened after 3 failures, no more calls
|
||||
assert set(states) == {"PENDING_PROVIDERS_OFFLINE"}
|
||||
|
||||
|
||||
def test_cosmos_without_token_is_not_configured_and_costs_nothing(env):
|
||||
cosmos = Cosmos(httpx.Client(transport=httpx.MockTransport(lambda r: pytest.fail("no call expected"))), None)
|
||||
r = run(env, [cosmos], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||
assert r["providers"][0]["reason"] == "NOT_CONFIGURED" and r["state"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_paid_provider_guard():
|
||||
class S: allow_paid_providers = False; cosmos_token = None
|
||||
assert [p.name for p in build_providers(S())] == ["oscbr", "kodebar", "upcitemdb", "openfoodfacts", "openproductsfacts", "openbeautyfacts"]
|
||||
|
||||
|
||||
# ---- provider contract tests against recorded fixtures (httpx.MockTransport, no network) ----------------------------------------------------------
|
||||
def _client(handler):
|
||||
return httpx.Client(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def test_upcitemdb_contract():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["url"] = str(request.url)
|
||||
return httpx.Response(200, json={"code": "OK", "total": 1, "items": [{"ean": G1, "title": "Cola 350ml", "brand": "X", "category": "Beverages", "size": "350ml"}]},
|
||||
headers={"X-RateLimit-Limit": "100", "X-RateLimit-Remaining": "99", "X-RateLimit-Reset": "1900000000"})
|
||||
p = UpcItemDb(_client(handler)).lookup_gtin(G1)
|
||||
assert p.found and p.title == "Cola 350ml" and p.ncm is None and "trial/lookup" in seen["url"]
|
||||
assert p.headers["x-ratelimit-remaining"] == "99"
|
||||
assert not UpcItemDb(_client(lambda r: httpx.Response(200, json={"code": "OK", "total": 0, "items": []}))).lookup_gtin(G1).found
|
||||
|
||||
|
||||
def test_openfoodfacts_contract_and_user_agent():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["ua"] = request.headers.get("user-agent")
|
||||
return httpx.Response(200, json={"status": 1, "product": {"product_name": "Biscoito", "brands": "Marca, Outra", "quantity": "130 g"}})
|
||||
off = OpenFoodFacts(httpx.Client(transport=httpx.MockTransport(handler), headers={"User-Agent": "GTIFiscalHub/1.0 (+contact)"}))
|
||||
p = off.lookup_gtin(G1)
|
||||
assert p.found and p.brand == "Marca" and "GTIFiscalHub" in seen["ua"]
|
||||
assert not OpenFoodFacts(_client(lambda r: httpx.Response(200, json={"status": 0, "status_verbose": "product not found"}))).lookup_gtin(G1).found
|
||||
|
||||
|
||||
def test_cosmos_contract_and_errors():
|
||||
body = {"description": "COCA COLA 350ML", "brand": {"name": "Coca"}, "ncm": {"code": "2202.10.00"}, "cest": {"code": "03.007.00"}}
|
||||
p = Cosmos(_client(lambda r: httpx.Response(200, json=body)), "tok").lookup_gtin(G1)
|
||||
assert p.ncm == "22021000" and p.cest == "0300700" and p.brand == "Coca"
|
||||
assert not Cosmos(_client(lambda r: httpx.Response(404, json={})), "tok").lookup_gtin(G1).found
|
||||
for status, kind in ((429, "RATE_LIMITED"), (401, "AUTH_ERROR"), (503, "SERVER_ERROR")):
|
||||
with pytest.raises(ProviderError) as e:
|
||||
Cosmos(_client(lambda r, s=status: httpx.Response(s)), "tok").lookup_gtin(G1)
|
||||
assert e.value.kind == kind
|
||||
def boom(request): raise httpx.ReadTimeout("t")
|
||||
with pytest.raises(ProviderError) as e:
|
||||
Cosmos(_client(boom), "tok").lookup_gtin(G1)
|
||||
assert e.value.kind == "TIMEOUT"
|
||||
|
||||
|
||||
def test_oscbr_optional_and_contract():
|
||||
calls = []
|
||||
assert not Oscbr(_client(lambda r: pytest.fail("no call expected"))).configured
|
||||
with pytest.raises(ProviderError):
|
||||
Oscbr(_client(lambda r: pytest.fail("no call expected"))).lookup_gtin(G1)
|
||||
|
||||
def handler(request):
|
||||
calls.append(str(request.url))
|
||||
if request.url.path == "/oauth/token":
|
||||
assert request.headers["authorization"].startswith("Basic ")
|
||||
return httpx.Response(200, json={"token": "tkn"})
|
||||
assert request.headers["authorization"] == "Bearer tkn"
|
||||
return httpx.Response(200, json={"ean": G1, "nome": "COLA 350ML", "marca": "X", "ncm": "2202.10.00", "cest": "03.007.00"})
|
||||
p = Oscbr(_client(handler), None, "u", "p")
|
||||
first, second = p.lookup_gtin(G1), p.lookup_gtin(G1)
|
||||
assert first.ncm == "22021000" and first.cest == "0300700" and second.found
|
||||
assert sum(1 for c in calls if "oauth" in c) == 1 # token reused for ~55 min
|
||||
bad = Oscbr(_client(lambda r: httpx.Response(401)), None, "u", "p")
|
||||
with pytest.raises(ProviderError) as e:
|
||||
bad.lookup_gtin(G1)
|
||||
assert e.value.kind == "AUTH_ERROR"
|
||||
|
||||
|
||||
def test_kodebar_identity_only_optional_and_contract():
|
||||
assert not Kodebar(_client(lambda r: pytest.fail("no call expected"))).configured
|
||||
def handler(request):
|
||||
assert request.headers["x-api-key"] == "k" and request.url.params["gtin"] == G1
|
||||
return httpx.Response(200, json={"gtin": G1, "nome": "COCA COLA 350ML", "marca": "Coca", "thumbnail": "u", "barcode_url": "b", "qr_code_url": "q"})
|
||||
p = Kodebar(_client(handler), "k").lookup_gtin(G1)
|
||||
assert p.found and p.title == "COCA COLA 350ML" and p.ncm is None and p.cest is None # identity only, never NCM
|
||||
assert not Kodebar(_client(lambda r: httpx.Response(200, json={})), "k").lookup_gtin(G1).found
|
||||
with pytest.raises(ProviderError) as e:
|
||||
Kodebar(_client(lambda r: httpx.Response(429)), "k").lookup_gtin(G1)
|
||||
assert e.value.kind == "RATE_LIMITED"
|
||||
|
||||
|
||||
def test_backfill_respects_operational_reserve(env):
|
||||
fake = Fake("cosmos", COSMOS(), True, daily_limit=10)
|
||||
gw = ProviderGateway(env, [fake])
|
||||
outs = [gw.lookup(fake, gtin(f"78970000{i:04d}"), "BACKFILL", 20).state for i in range(10)]
|
||||
assert outs.count("SKIPPED") == 2 and fake.calls == 8 # 20% of 10 kept for new products
|
||||
assert gw.lookup(fake, gtin("789700009999"), "OPERATIONAL").state in ("FOUND", "NOT_FOUND")
|
||||
|
||||
|
||||
def test_technical_placeholder_ncm_is_treated_as_not_classified_but_real_values_are_not(env):
|
||||
p = kb.upsert_product(env, "0" + G1, "COCA-COLA ORIGINAL LATA 350 ML")
|
||||
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||
kb.add_evidence(env, p, "ncm", "22021000", "x", family=fam)
|
||||
env.commit()
|
||||
for value, expected in (("00000000", "AUTO_FILL_SAFE"), ("0000.00.00", "AUTO_FILL_SAFE"), ("22021000", "ALREADY_SET"), ("99999999", "ALREADY_SET"), ("0", "ALREADY_SET"), ("", "AUTO_FILL_SAFE")):
|
||||
r = run(env, [], G1, "COCA-COLA ORIGINAL LATA 350 ML", current={"CLASSIFICACAO_FISCAL": value})
|
||||
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == expected, value
|
||||
r = run(env, [], G1, "COCA-COLA ORIGINAL LATA 350 ML", current={"CLASSIFICACAO_FISCAL": "00000000"})
|
||||
assert r["fields"]["IPI"]["value"] == "0.0" # CEST/IPI follow the NCM that is about to replace the filler
|
||||
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from central.app.ai.questionnaire import build_questionnaire, group_questions, validate_answers
|
||||
|
||||
|
||||
def test_questionnaire_groups_by_scope_and_preserves_effects():
|
||||
rows = [
|
||||
{"QUESTION_ID": "Q1", "DATABASE": "ACME", "SCOPE": "COMPANY", "GROUP": "", "KEY": "entry_scope",
|
||||
"VALID_OPTIONS": ["INTRA", "INTER", "MIXED_OR_DYNAMIC"], "PRODUCTS_AFFECTED": 2, "CELLS": 3,
|
||||
"QUESTION": "Escopo?", "EXAMPLES": "P1; P2"},
|
||||
{"QUESTION_ID": "Q2", "DATABASE": "ACME", "SCOPE": "GROUP", "GROUP": "G1", "KEY": "product_role",
|
||||
"VALID_OPTIONS": ["RESALE"], "PRODUCTS_AFFECTED": 1, "CELLS": 1, "QUESTION": "Papel?"},
|
||||
]
|
||||
|
||||
result = build_questionnaire(rows)
|
||||
|
||||
assert [item["question_id"] for item in result] == ["Q1", "Q2"]
|
||||
assert result[0]["affected"] == {"products": 2, "cells": 3}
|
||||
assert "MIXED_OR_DYNAMIC" in result[0]["options"]
|
||||
assert result[1]["scope_key"] == "G1"
|
||||
|
||||
|
||||
def test_answers_are_validated_without_persisting_and_mixed_scope_is_explicit():
|
||||
questions = build_questionnaire([{
|
||||
"QUESTION_ID": "Q1", "DATABASE": "ACME", "SCOPE": "COMPANY", "GROUP": "", "KEY": "entry_scope",
|
||||
"VALID_OPTIONS": ["INTRA", "INTER", "MIXED_OR_DYNAMIC"], "QUESTION": "Escopo?",
|
||||
}])
|
||||
|
||||
answers = validate_answers(questions, {"Q1": "MIXED_OR_DYNAMIC"})
|
||||
|
||||
assert answers["entries"] == [{"scope": "COMPANY", "scope_key": "", "key": "entry_scope", "value": "MIXED_OR_DYNAMIC", "reason": None, "company_key": "ACME"}]
|
||||
assert answers["effects"][0]["behavior"] == "no_static_cfop_entrada"
|
||||
|
||||
with pytest.raises(ValueError, match="nothing was persisted"):
|
||||
validate_answers(questions, {"Q1": "OUTSIDE"})
|
||||
|
||||
|
||||
def test_store_ipi_profile_requires_explicit_choice_and_only_plans_rollback():
|
||||
questions = build_questionnaire([{
|
||||
"QUESTION_ID": "QIPI-ACME", "DATABASE": "ACME", "SCOPE": "COMPANY", "GROUP": "", "KEY": "store_ipi_profile",
|
||||
"VALID_OPTIONS": ["POPULATE_REFERENCE_RATE", "LEAVE_NOT_APPLICABLE"], "QUESTION": "IPI?",
|
||||
"PRODUCTS_AFFECTED": 4, "CELLS": 4,
|
||||
}])
|
||||
|
||||
result = validate_answers(questions, {"QIPI-ACME": "LEAVE_NOT_APPLICABLE"})
|
||||
|
||||
assert result["entries"][0]["value"] == "LEAVE_NOT_APPLICABLE"
|
||||
assert result["rollback_plan"] == [{"database": "ACME", "field": "IPI", "affected_cells": 4}]
|
||||
assert json.dumps(result)
|
||||
|
||||
|
||||
def test_equivalent_questions_are_grouped_with_aggregated_impact():
|
||||
questions = build_questionnaire([
|
||||
{"QUESTION_ID": "Q1", "DATABASE": "ACME", "SCOPE": "GROUP", "GROUP": "REFRIGERANTES", "KEY": "product_role", "VALID_OPTIONS": ["RESALE"], "CELLS": 10, "PRODUCTS_AFFECTED": 3, "QUESTION": "Qual o papel?"},
|
||||
{"QUESTION_ID": "Q2", "DATABASE": "ACME", "SCOPE": "GROUP", "GROUP": "REFRIGERANTES", "KEY": "product_role", "VALID_OPTIONS": ["RESALE"], "CELLS": 20, "PRODUCTS_AFFECTED": 5, "QUESTION": "Qual o papel?"},
|
||||
])
|
||||
groups = group_questions(questions)
|
||||
assert len(groups) == 1 and groups[0]["question_ids"] == ["Q1", "Q2"]
|
||||
assert groups[0]["affected"] == {"products": 8, "cells": 30}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Missão 5 / Fase 1.2: 'Tabelas oficiais' admin screen - list, diff, approve/reject (CSRF, audited)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.models import AuditEvent, Dataset
|
||||
from central.app.reference_registry import register_draft
|
||||
from central.tests.test_control_panel_pages import _panel_client
|
||||
from central.tests.test_rtc_security import _become, _post
|
||||
|
||||
|
||||
def _seed_draft(client, **kw):
|
||||
with client._session_factory() as s:
|
||||
d = register_draft(s, kind=kw.get("kind", "NCM"), provider=kw.get("provider", "Siscomex"), source_url="https://x",
|
||||
sha256=kw.get("sha256", "a" * 64), version_label=kw.get("version_label", "v1"), record_count=kw.get("record_count", 100),
|
||||
parse_errors=kw.get("parse_errors") or {})
|
||||
return d.id
|
||||
|
||||
|
||||
def test_page_requires_admin():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "PADRAO")
|
||||
resp = client.get("/admin/reference-datasets", follow_redirects=False)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_admin_sees_the_draft_and_its_diff():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_seed_draft(client)
|
||||
_become(client, "ADMIN")
|
||||
page = client.get("/admin/reference-datasets").text
|
||||
assert "Tabelas oficiais" in page and "NCM" in page and "DRAFT" in page
|
||||
assert "Primeira vers" in page # first version of this kind, no vigente to diff against
|
||||
|
||||
|
||||
def test_approve_promotes_and_is_audited():
|
||||
client, agent_id, _ = _panel_client()
|
||||
dataset_id = _seed_draft(client)
|
||||
_become(client, "ADMIN")
|
||||
resp = _post(client, f"/admin/reference-datasets/{dataset_id}/decide", action="approve")
|
||||
assert resp.status_code == 303
|
||||
with client._session_factory() as s:
|
||||
assert s.get(Dataset, dataset_id).status == "VIGENTE"
|
||||
acts = [e.action for e in s.scalars(select(AuditEvent))]
|
||||
assert "REFERENCE_DATASET_APPROVE" in acts
|
||||
|
||||
|
||||
def test_reject_never_promotes():
|
||||
client, agent_id, _ = _panel_client()
|
||||
dataset_id = _seed_draft(client)
|
||||
_become(client, "ADMIN")
|
||||
assert _post(client, f"/admin/reference-datasets/{dataset_id}/decide", action="reject").status_code == 303
|
||||
with client._session_factory() as s:
|
||||
assert s.get(Dataset, dataset_id).status == "REJECTED"
|
||||
|
||||
|
||||
def test_approve_with_parse_errors_is_refused_with_422():
|
||||
client, agent_id, _ = _panel_client()
|
||||
dataset_id = _seed_draft(client, parse_errors={"MINIMUM_ENTRIES": "too few"})
|
||||
_become(client, "ADMIN")
|
||||
resp = _post(client, f"/admin/reference-datasets/{dataset_id}/decide", action="approve")
|
||||
assert resp.status_code == 422
|
||||
with client._session_factory() as s:
|
||||
assert s.get(Dataset, dataset_id).status == "DRAFT"
|
||||
|
||||
|
||||
def test_non_admin_cannot_decide_and_it_is_audited_as_a_denial():
|
||||
client, agent_id, _ = _panel_client()
|
||||
dataset_id = _seed_draft(client)
|
||||
_become(client, "PADRAO")
|
||||
resp = _post(client, f"/admin/reference-datasets/{dataset_id}/decide", action="approve")
|
||||
assert resp.status_code == 403
|
||||
with client._session_factory() as s:
|
||||
assert s.get(Dataset, dataset_id).status == "DRAFT"
|
||||
|
||||
|
||||
def test_wrong_csrf_is_refused():
|
||||
client, agent_id, _ = _panel_client()
|
||||
dataset_id = _seed_draft(client)
|
||||
_become(client, "ADMIN")
|
||||
resp = client.post(f"/admin/reference-datasets/{dataset_id}/decide", data={"csrf_token": "bad", "action": "approve"}, follow_redirects=False)
|
||||
assert resp.status_code in (400, 403)
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.main import create_app
|
||||
from central.app.models import AnpProductCode, Base, CfopEntry, Dataset, FcpRate, NcmUtribEntry
|
||||
|
||||
|
||||
def _client_with_reference_data():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
dataset = Dataset(kind="CFOP", provider="p", sha256="a" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(dataset); session.flush()
|
||||
session.add(CfopEntry(dataset_id=dataset.id, code="1101", title="Compra", description="desc"))
|
||||
|
||||
d2 = Dataset(kind="NCM_UTRIB", provider="p", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(d2); session.flush()
|
||||
session.add(NcmUtribEntry(dataset_id=d2.id, ncm="01012100", utrib_abbreviation="UN", utrib_description="Unidade"))
|
||||
|
||||
d3 = Dataset(kind="ANP", provider="p", sha256="c" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(d3); session.flush()
|
||||
session.add(AnpProductCode(dataset_id=d3.id, cprod_anp="110203073"))
|
||||
|
||||
d4 = Dataset(kind="FCP", provider="p", sha256="d" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(d4); session.flush()
|
||||
session.add(FcpRate(dataset_id=d4.id, uf_code="29", uf="BA", uf_name="BAHIA", aliq_1="Fixo:2.00", observacao="obs"))
|
||||
session.commit()
|
||||
app = create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_reference_endpoints_found_and_not_found():
|
||||
client = _client_with_reference_data()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
assert client.get("/v1/reference/cfop/1101", headers=headers).json()["found"] is True
|
||||
assert client.get("/v1/reference/cfop/9999", headers=headers).json()["found"] is False
|
||||
|
||||
assert client.get("/v1/reference/ncm_utrib/01012100", headers=headers).json()["found"] is True
|
||||
assert client.get("/v1/reference/ncm_utrib/99999999", headers=headers).json()["found"] is False
|
||||
|
||||
assert client.get("/v1/reference/anp/110203073", headers=headers).json()["found"] is True
|
||||
assert client.get("/v1/reference/anp/000000000", headers=headers).json()["found"] is False
|
||||
|
||||
fcp = client.get("/v1/reference/fcp/BA", headers=headers).json()
|
||||
assert fcp["found"] is True
|
||||
assert fcp["aliq_1"] == "Fixo:2.00"
|
||||
assert client.get("/v1/reference/fcp/ZZ", headers=headers).json()["found"] is False
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Missão 5 / Fase 1: Reference Data Registry - DRAFT-first ingestion, admin approval, diff summary.
|
||||
Never auto-activates (unlike the 9 legacy dataset kinds in central/app/datasets/service.py, which
|
||||
this module never touches)."""
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.models import Base, Dataset
|
||||
from central.app.reference_registry import RegistryError, approve_draft, diff_against_vigente, list_registry, register_draft, reject_draft
|
||||
|
||||
|
||||
def engine():
|
||||
e = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(e)
|
||||
return e
|
||||
|
||||
|
||||
def test_register_draft_never_auto_activates():
|
||||
with Session(engine()) as s:
|
||||
d = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="a" * 64, version_label="v1", record_count=15156)
|
||||
assert d.status == "DRAFT"
|
||||
assert s.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "VIGENTE")) is None
|
||||
|
||||
|
||||
def test_resubmitting_the_same_sha256_returns_the_existing_row_not_a_duplicate():
|
||||
with Session(engine()) as s:
|
||||
d1 = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="a" * 64, version_label="v1", record_count=15156)
|
||||
d2 = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="a" * 64, version_label="v1-again", record_count=99999)
|
||||
assert d1.id == d2.id and d2.version_label == "v1" # untouched, not overwritten by the resubmission
|
||||
assert len(list(s.scalars(select(Dataset)))) == 1
|
||||
|
||||
|
||||
def test_approve_promotes_to_vigente_and_supersedes_the_previous_one():
|
||||
with Session(engine()) as s:
|
||||
first = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="a" * 64, version_label="v1", record_count=100)
|
||||
approve_draft(s, first.id, admin_name="admin1")
|
||||
assert s.get(Dataset, first.id).status == "VIGENTE"
|
||||
second = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="b" * 64, version_label="v2", record_count=101)
|
||||
diff = diff_against_vigente(s, second)
|
||||
assert diff["current_vigente"]["version_label"] == "v1" and diff["record_count_delta"] == 1 and not diff["is_first_version"]
|
||||
approve_draft(s, second.id, admin_name="admin2")
|
||||
assert s.get(Dataset, first.id).status == "OBSOLETO"
|
||||
assert s.get(Dataset, second.id).status == "VIGENTE"
|
||||
assert s.get(Dataset, second.id).approved_by == "admin2"
|
||||
|
||||
|
||||
def test_approve_refuses_a_dataset_with_parse_errors():
|
||||
with Session(engine()) as s:
|
||||
d = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="a" * 64, version_label="v1", record_count=1, parse_errors={"MINIMUM_ENTRIES": "too few"})
|
||||
try:
|
||||
approve_draft(s, d.id, admin_name="admin1")
|
||||
assert False, "expected RegistryError"
|
||||
except RegistryError as e:
|
||||
assert "parse errors" in str(e)
|
||||
assert s.get(Dataset, d.id).status == "DRAFT"
|
||||
|
||||
|
||||
def test_approve_refuses_a_non_draft_dataset():
|
||||
with Session(engine()) as s:
|
||||
d = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="a" * 64, version_label="v1", record_count=1)
|
||||
approve_draft(s, d.id, admin_name="admin1")
|
||||
try:
|
||||
approve_draft(s, d.id, admin_name="admin2")
|
||||
assert False, "expected RegistryError"
|
||||
except RegistryError as e:
|
||||
assert "only a DRAFT" in str(e)
|
||||
|
||||
|
||||
def test_reject_never_touches_the_current_vigente():
|
||||
with Session(engine()) as s:
|
||||
first = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="a" * 64, version_label="v1", record_count=1)
|
||||
approve_draft(s, first.id, admin_name="admin1")
|
||||
bad = register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="b" * 64, version_label="v2-bad", record_count=1)
|
||||
reject_draft(s, bad.id, admin_name="admin2")
|
||||
assert s.get(Dataset, bad.id).status == "REJECTED"
|
||||
assert s.get(Dataset, first.id).status == "VIGENTE"
|
||||
|
||||
|
||||
def test_diff_against_vigente_for_the_first_ever_version_of_a_kind():
|
||||
with Session(engine()) as s:
|
||||
d = register_draft(s, kind="CEST", provider="Confaz", source_url="https://x", sha256="c" * 64, version_label="v1", record_count=1051)
|
||||
diff = diff_against_vigente(s, d)
|
||||
assert diff["is_first_version"] is True and diff["current_vigente"] is None
|
||||
|
||||
|
||||
def test_list_registry_orders_by_kind_then_newest_first():
|
||||
with Session(engine()) as s:
|
||||
register_draft(s, kind="NCM", provider="Siscomex", source_url="https://x", sha256="a" * 64, version_label="v1", record_count=1)
|
||||
register_draft(s, kind="CEST", provider="Confaz", source_url="https://x", sha256="c" * 64, version_label="v1", record_count=1)
|
||||
rows = list_registry(s)
|
||||
assert {r.kind for r in rows} == {"NCM", "CEST"}
|
||||
@@ -0,0 +1,77 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.auth import authenticate, token_hash
|
||||
from central.app.models import Base
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "register_agent.py"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("register_agent", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
register_agent_module = load_module()
|
||||
|
||||
|
||||
def test_register_agent_creates_tenant_and_hashed_token_only():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
info, token = register_agent_module.register_agent(session, "Restaurante X", "loja-01")
|
||||
found = authenticate(session, token)
|
||||
assert found is not None
|
||||
assert found.id == info["id"]
|
||||
assert found.token_hash == token_hash(token)
|
||||
assert found.token_hash != token # only the hash is ever persisted
|
||||
|
||||
# Mirrors the real CLI's main(): reading the returned info AFTER the
|
||||
# session (and its `with` block) has closed. This is exactly the scenario
|
||||
# that broke when register_agent() returned live ORM objects instead of
|
||||
# plain values (DetachedInstanceError) — must keep working unattached.
|
||||
assert info["enabled"] is True
|
||||
assert isinstance(info["tenant_id"], int)
|
||||
|
||||
|
||||
def test_register_agent_reuses_existing_tenant():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
info1, _ = register_agent_module.register_agent(session, "Restaurante X", "loja-01")
|
||||
info2, _ = register_agent_module.register_agent(session, "Restaurante X", "loja-02")
|
||||
|
||||
assert info1["tenant_id"] == info2["tenant_id"]
|
||||
assert info1["id"] != info2["id"]
|
||||
|
||||
|
||||
def test_register_agent_generates_unique_tokens():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
_, token1 = register_agent_module.register_agent(session, "A", "a1")
|
||||
_, token2 = register_agent_module.register_agent(session, "A", "a2")
|
||||
assert token1 != token2
|
||||
|
||||
|
||||
def test_disabled_agent_cannot_authenticate():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from central.app.auth import authenticate, token_hash
|
||||
from central.app.models import Agent, Base, Tenant
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as s:
|
||||
tenant = Tenant(name="t"); s.add(tenant); s.flush()
|
||||
agent = Agent(tenant_id=tenant.id, name="a", token_hash=token_hash("secret-token")); s.add(agent); s.commit()
|
||||
assert authenticate(s, "secret-token").id == agent.id
|
||||
agent.enabled = False; s.commit()
|
||||
assert authenticate(s, "secret-token") is None
|
||||
assert authenticate(s, "wrong") is None
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Semantic candidate retrieval V2: hierarchical official corpus + KB-neighbor bridge + hybrid ranking."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import Base, Dataset, KbKnowledge, KbObservation, KbProduct, NcmEntry, Tenant
|
||||
|
||||
|
||||
def _engine():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def _seed(session):
|
||||
ncm = Dataset(kind="NCM", provider="Siscomex", sha256="n" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=7)
|
||||
session.add(ncm); session.flush()
|
||||
rows = [
|
||||
("22", "BEBIDAS, LIQUIDOS ALCOOLICOS E VINAGRES"),
|
||||
("2202", "AGUAS, INCLUIDAS AS AGUAS GASEIFICADAS E MINERAIS"),
|
||||
("2202.10", "AGUAS MINERAIS E GASEIFICADAS"),
|
||||
("22021000", "AGUAS MINERAIS NAO GASEIFICADAS"),
|
||||
("61", "VESTUARIO E SEUS ACESSORIOS, DE MALHA"),
|
||||
("6105", "CAMISAS DE MALHA, DE USO MASCULINO"),
|
||||
("61051000", "DE ALGODAO"),
|
||||
]
|
||||
for code, description in rows:
|
||||
session.add(NcmEntry(dataset_id=ncm.id, code=code.replace(".", ""), description=description, valid_from="", valid_to=""))
|
||||
session.flush()
|
||||
# two tenants already selling mineral water with a validated NCM
|
||||
tenant_a, tenant_b = Tenant(name="A"), Tenant(name="B")
|
||||
session.add_all([tenant_a, tenant_b]); session.flush()
|
||||
p1 = KbProduct(fingerprint="f1", normalized_description="AGUA MINERAL SEM GAS 500ML")
|
||||
p2 = KbProduct(fingerprint="f2", normalized_description="AGUA MINERAL GARRAFA 1L")
|
||||
p3 = KbProduct(fingerprint="f3", normalized_description="CAMISA MASCULINA MALHA ALGODAO")
|
||||
session.add_all([p1, p2, p3]); session.flush()
|
||||
for product, ncm_value, tenant in ((p1, "22021000", tenant_a), (p2, "22021000", tenant_b), (p3, "61051000", tenant_a)):
|
||||
session.add(KbKnowledge(kb_product_id=product.id, knowledge_key="validated_ncm", value=ncm_value, source="OFFICIAL_DATASET", confidence=1.0))
|
||||
session.add(KbObservation(kb_product_id=p1.id, tenant_id=tenant_a.id, company_key="A", erp_code="1", database_label="A", observed_fields={}))
|
||||
session.add(KbObservation(kb_product_id=p2.id, tenant_id=tenant_b.id, company_key="B", erp_code="2", database_label="B", observed_fields={}))
|
||||
session.add(KbObservation(kb_product_id=p3.id, tenant_id=tenant_a.id, company_key="A", erp_code="3", database_label="A", observed_fields={}))
|
||||
session.commit()
|
||||
return ncm.sha256
|
||||
|
||||
|
||||
def test_enriched_corpus_carries_ancestor_context():
|
||||
from central.app.ai.retrieval import EnrichedNcmCorpus
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_seed(session)
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
doc = corpus.document("22021000")
|
||||
# chapter + position + subposition context enriches the very short legal text (stemmed)
|
||||
assert "BEBIDA" in doc and "AGUA" in doc
|
||||
assert "VESTUARIO" not in doc
|
||||
|
||||
|
||||
def test_hierarchical_retrieval_prefers_right_positions():
|
||||
from central.app.ai.retrieval import EnrichedNcmCorpus, hierarchical_candidates
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_seed(session)
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
codes = [code for code, _, _ in hierarchical_candidates(corpus, "AGUA MINERAL", k=2)]
|
||||
assert codes[0] == "22021000"
|
||||
|
||||
|
||||
def test_kb_neighbor_bridge_maps_commercial_to_ncm():
|
||||
from central.app.ai.retrieval import KbNeighborIndex, kb_neighbor_candidates
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_seed(session)
|
||||
index = KbNeighborIndex.build(session)
|
||||
neighbors = kb_neighbor_candidates(index, "AGUA MINERAL PET 500ML", k=5)
|
||||
assert neighbors and neighbors[0][0] == "22021000"
|
||||
# cross-tenant agreement is reported, never auto-majority
|
||||
assert neighbors[0][3] >= 2
|
||||
|
||||
|
||||
def test_hybrid_merges_official_and_kb_rankings():
|
||||
from central.app.ai.retrieval import EnrichedNcmCorpus, KbNeighborIndex, hybrid_candidates
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
_seed(session)
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
index = KbNeighborIndex.build(session)
|
||||
hybrid = hybrid_candidates(corpus, index, "CAMISA MASCULINA ALGODAO", k=2)
|
||||
assert hybrid[0][0] == "61051000"
|
||||
scores = hybrid_candidates(corpus, index, "AGUA MINERAL SEM GAS", k=2)
|
||||
assert scores[0][0] == "22021000"
|
||||
|
||||
|
||||
def test_lexicon_expands_abbreviations_supported_by_kb():
|
||||
from central.app.ai.retrieval import derive_abbreviation_lexicon, expand_stems
|
||||
corpus = {
|
||||
"REFRIGERANTE COCA 2L": "22021000",
|
||||
"REFRI GUARANA 1L": "22021000",
|
||||
"REFRI LARANJA 350ML": "22021000",
|
||||
"CAMISA POLO ALGODAO": "61051000",
|
||||
"CAM POLO MASC": "61051000",
|
||||
"CAM POLO AZUL": "61051000",
|
||||
"BOTA COURO MASC": "64039900",
|
||||
}
|
||||
lexicon = derive_abbreviation_lexicon(corpus, min_support=2)
|
||||
assert lexicon.get("REFRI") == "REFRIGERANTE"
|
||||
assert lexicon.get("CAM") == "CAMISA"
|
||||
expanded = expand_stems({"REFRI", "COLA"}, lexicon)
|
||||
assert "REFRIGERANTE" in expanded
|
||||
|
||||
|
||||
def test_commercial_tokens_strip_units_and_sizes():
|
||||
from central.app.ai.retrieval import commercial_tokens
|
||||
tokens = commercial_tokens("CREME TRATAMENTO NOVEX 1KG CX 12 COR AZUL TAM M REF 453")
|
||||
assert "NOVEX" in tokens and "CREME" in tokens and "TRATAMENTO" in tokens
|
||||
assert "1KG" not in tokens and "CX" not in tokens and "COR" not in tokens and "TAM" not in tokens
|
||||
assert "AZUL" not in tokens and "REF" not in tokens and "453" not in tokens
|
||||
assert "12" not in tokens
|
||||
@@ -0,0 +1,31 @@
|
||||
def test_retrieval_v3_gtin_evidence_is_candidate_only():
|
||||
from central.app.ai.retrieval_v3 import retrieval_v3_candidates
|
||||
|
||||
class Corpus:
|
||||
pass
|
||||
corpus = Corpus()
|
||||
corpus.documents = {}
|
||||
corpus.descriptions = {}
|
||||
corpus.positions = {}
|
||||
corpus.children = {}
|
||||
class Index:
|
||||
entries = []
|
||||
# Empty V2 corpus cannot manufacture an NCM, proving no candidate is invented.
|
||||
assert retrieval_v3_candidates(corpus, Index(), "produto", {"gtin_ncm": "22011000"}) == []
|
||||
|
||||
|
||||
def test_prepare_candidate_set_is_fingerprinted_and_non_decisional():
|
||||
from central.app.ai.retrieval_v3 import prepare_candidate_set
|
||||
|
||||
work = type("WorkItem", (), {"task_type": "INVALID_NCM", "dedup_key": "x", "payload": {"description": "Agua mineral"}})()
|
||||
prepared = prepare_candidate_set(work, [("22011000", "AGUAS MINERAIS", 0.9, 0.8, 0.1, 2)], dataset_versions={"ncm": "v1"})
|
||||
assert prepared["top20_candidate_ncms"][0]["ncm"] == "22011000"
|
||||
assert prepared["input_fingerprint"]
|
||||
assert "selected_ncm" not in prepared
|
||||
|
||||
|
||||
def test_product_role_context_is_compact_and_evidence_only():
|
||||
from central.app.ai.retrieval_v3 import compact_product_role_context
|
||||
|
||||
result = compact_product_role_context([{"description": "Coca Cola"}, {"description": "coca-cola"}, {"description": "Pepsi"}], limit=2)
|
||||
assert len(result["examples"]) == 2 and result["decision"] is None and result["evidence_only"] is True
|
||||
@@ -0,0 +1,62 @@
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.datasets import bootstrap_v1_extra as parsers
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base, RtcCstIbsCbsEntry
|
||||
|
||||
ROOT = Path(__file__).parents[2] / "sources" / "raw" / "bootstrap_v1"
|
||||
CCLASS = ROOT / "cClassTrib 2026-06-22.xlsx"
|
||||
CBS = ROOT / "IT 2026.002 v.1.00 - Aliquotas da CBS.xlsx"
|
||||
pytestmark = pytest.mark.skipif(not (CCLASS.exists() and CBS.exists()), reason="RTC bootstrap files not present locally")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _cclass():
|
||||
return parsers.parse_rtc_cclasstrib(CCLASS.read_bytes())
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_cclasstrib_keys_unique_and_rich_fields_kept():
|
||||
p = _cclass()
|
||||
codes = [e["cclasstrib"] for e in p.entries]
|
||||
assert len(codes) == len(set(codes)) >= 100
|
||||
assert all(len(c) == 6 for c in codes)
|
||||
assert p.entries[0]["indicators"] and p.entries[0]["d_ini_vig"]
|
||||
assert {e["cst"] for e in p.entries} <= {e["cst"] for e in p.extra_entries}
|
||||
|
||||
|
||||
def test_cbs_rates_2026_numeric_and_2027_pending():
|
||||
p = parsers.parse_rtc_cbs_rates(CBS.read_bytes())
|
||||
by_year = {e["year"]: e for e in p.entries}
|
||||
assert by_year[2026]["rate"] == pytest.approx(0.009)
|
||||
assert by_year[2027]["rate"] is None and by_year[2027]["rate_text"]
|
||||
|
||||
|
||||
def test_import_idempotent_lookup_and_versions():
|
||||
with _session() as s:
|
||||
svc = DatasetService(s)
|
||||
assert svc.dataset_versions()["rtc_cclasstrib"] is None
|
||||
now = datetime.now(timezone.utc)
|
||||
a = svc.import_rtc_cclasstrib(_cclass(), "file://x", now)
|
||||
b = svc.import_rtc_cclasstrib(_cclass(), "file://x", now)
|
||||
assert a.id == b.id and b.status == "ACTIVE" and b.record_count == len(_cclass().entries)
|
||||
assert s.query(RtcCstIbsCbsEntry).count() == len(_cclass().extra_entries)
|
||||
assert svc.rtc_cclasstrib(code="000001")
|
||||
|
||||
cbs_payload = parsers.parse_rtc_cbs_rates(CBS.read_bytes())
|
||||
c = svc.import_rtc_cbs_rates(cbs_payload, "file://y", now)
|
||||
assert svc.import_rtc_cbs_rates(cbs_payload, "file://y", now).id == c.id
|
||||
assert svc.rtc_cbs_rate(2026).rate == pytest.approx(0.009)
|
||||
v = svc.dataset_versions()
|
||||
assert v["rtc_cclasstrib"] and v["rtc_cbs_rates"]
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Security review of the centrally controlled cClassTrib write: who can change the mode, what the Agent can and cannot decide, tenant scoping, no secrets in audits."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.models import AuditEvent, Installation, RtcCandidate, RtcWriteAudit
|
||||
from central.tests.test_control_panel_pages import _panel_client, _seed_rtc
|
||||
from central.tests.test_zero_touch_hub import H, OPERATIONAL, _client, age_last_sync, finish, hb
|
||||
|
||||
|
||||
def test_new_rtc_routes_require_an_operator_session():
|
||||
client, agent_id, _ = _panel_client()
|
||||
client.cookies.clear()
|
||||
for method, path in (("get", f"/agents/{agent_id}/rtc"), ("post", f"/agents/{agent_id}/rtc-mode"), ("post", f"/agents/{agent_id}/rtc-candidates"), ("post", f"/agents/{agent_id}/rtc-rollback")):
|
||||
response = getattr(client, method)(path, follow_redirects=False)
|
||||
assert response.status_code in (303, 401, 403), (path, response.status_code)
|
||||
assert "login" in response.headers.get("location", "login") or response.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_default_mode_is_disabled_and_the_agent_cannot_choose_it():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED"
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
assert job["authorizations"]["rtc_write_mode"] == "DISABLED"
|
||||
# a forged result cannot change the mode, approve candidates or request rollbacks: none of those are read from the payload
|
||||
forged = {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_write_mode": "ENABLED", "authorizations": {"rtc_write_mode": "ENABLED", "rtc_approved_codes": ["X"]},
|
||||
"rtc_auto_apply": {"mode": "ENABLED", "hub_mode": "ENABLED", "preview": [{"product_code": "X", "proposed": "200003", "rule_id": "RTC-R3", "status": "APPROVED"}]}}
|
||||
client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": forged}, headers=H)
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED"
|
||||
assert s.scalars(select(RtcCandidate)).all() == [] # DISABLED stores no preview at all
|
||||
|
||||
|
||||
def test_preview_from_the_agent_never_arrives_approved_and_never_approves_itself():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
with Session(engine) as s:
|
||||
s.scalars(select(Installation)).one().rtc_write_mode = "APPROVAL_REQUIRED"
|
||||
s.commit()
|
||||
item = {"product_code": "X1", "description": "D", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "b", "status": "APPROVED"}
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": {"evaluated_all": True}, "rtc_auto_apply": {"preview": [item]}})
|
||||
with Session(engine) as s:
|
||||
assert [c.status for c in s.scalars(select(RtcCandidate)).all()] == ["PENDING"]
|
||||
age_last_sync(engine, 4000); hb(client)
|
||||
assert client.get("/v1/jobs/next", headers=H).json()["authorizations"]["rtc_approved_codes"] == []
|
||||
|
||||
|
||||
def test_a_rollback_result_cannot_touch_another_installations_audit():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
with Session(engine) as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
foreign = RtcWriteAudit(tenant_id=inst.tenant_id, installation_id=inst.id + 999, agent_id=inst.agent_id, product_code="F", field="CCLASSTRIB", outcome="APPLIED", new_value="200003", action="APPLY", rollback_state="REQUESTED")
|
||||
# the FK is not enforced by the SQLite test DB; the scoping is what is under test
|
||||
s.add(foreign); s.commit(); foreign_id = foreign.id
|
||||
done = {"outcome": "ROLLED_BACK", "product_code": "F", "field": "CCLASSTRIB", "old_value": "200003", "new_value": None, "action": "ROLLBACK", "rollback_of": foreign_id}
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"rollbacks": [done]}})
|
||||
with Session(engine) as s:
|
||||
assert s.get(RtcWriteAudit, foreign_id).rollback_state == "REQUESTED" # untouched
|
||||
|
||||
|
||||
def test_rollback_and_candidate_actions_are_scoped_to_the_operators_installation_and_audited():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_seed_rtc(client, agent_id, n=3)
|
||||
csrf = client.cookies.get("gti_csrf")
|
||||
with client._session_factory() as s:
|
||||
other = RtcWriteAudit(tenant_id=1, installation_id=987654, agent_id=agent_id, product_code="Z", field="CCLASSTRIB", outcome="APPLIED", new_value="200003", action="APPLY")
|
||||
s.add(other); s.commit(); other_id = other.id
|
||||
assert client.post(f"/agents/{agent_id}/rtc-rollback", data={"csrf_token": csrf, "audit_id": other_id, "confirm": "REVERTER"}).status_code == 409
|
||||
client.post(f"/agents/{agent_id}/rtc-candidates?all=1", data={"csrf_token": csrf, "action": "approve"}, follow_redirects=False)
|
||||
with client._session_factory() as s:
|
||||
actions = [a.action for a in s.scalars(select(AuditEvent)).all()]
|
||||
assert "RTC_CANDIDATES_APPROVE" in actions
|
||||
|
||||
|
||||
def test_audit_rows_never_carry_credentials_or_tokens():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
applied = {"outcome": "APPLIED", "product_code": "10", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY", "password": "p", "token": "t",
|
||||
"evidence": {"reason": "r"}}
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"applied": [applied]}})
|
||||
with Session(engine) as s:
|
||||
row = s.scalars(select(RtcWriteAudit)).one()
|
||||
blob = repr([getattr(row, c.name) for c in RtcWriteAudit.__table__.columns])
|
||||
assert "password" not in blob.lower() and "'t'" not in blob and "Bearer" not in blob
|
||||
assert not any("pass" in c.name or "token" in c.name or "secret" in c.name for c in RtcWriteAudit.__table__.columns)
|
||||
|
||||
|
||||
def test_agent_can_reread_only_its_own_current_authorization_and_it_follows_the_operator():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
assert client.get("/v1/agents/me/authorizations").status_code in (401, 403) # requires the Agent credential
|
||||
assert client.get("/v1/agents/me/authorizations", headers=H).json()["rtc_write_mode"] == "DISABLED"
|
||||
with Session(engine) as s:
|
||||
s.scalars(select(Installation)).one().rtc_write_mode = "ENABLED"
|
||||
s.commit()
|
||||
body = client.get("/v1/agents/me/authorizations", headers=H).json()
|
||||
assert body["rtc_write_mode"] == "ENABLED" and set(body) == {"rtc_write_mode", "rtc_approved_codes", "rtc_rollbacks", "features", "cfop_ippt_write_mode"}
|
||||
|
||||
|
||||
def _become(client, role):
|
||||
"""Turn the logged-in test operator into a Gerenciador SSO collaborator with the given perfil (identity source of truth is the SSO)."""
|
||||
from central.app.models import AdminUser
|
||||
with client._session_factory() as s:
|
||||
admin = s.scalars(select(AdminUser)).first()
|
||||
admin.auth_source, admin.password_hash, admin.external_subject, admin.external_role = "GTI_GERENCIADOR", None, "sso-subject-1", role
|
||||
s.commit()
|
||||
|
||||
|
||||
def _post(client, path, **data):
|
||||
return client.post(path, data={"csrf_token": client.cookies.get("gti_csrf"), **data}, follow_redirects=False)
|
||||
|
||||
|
||||
def test_write_modes_are_restricted_to_gerenciador_admins_and_every_denial_is_audited():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "PADRAO")
|
||||
for mode, extra in (("ENABLED", {"confirm": "PREENCHER"}), ("APPROVAL_REQUIRED", {})):
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode=mode, **extra).status_code == 403
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-candidates", action="approve", all="1").status_code == 403
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-candidates", action="apply_approved").status_code == 403
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-rollback", audit_id="1", confirm="REVERTER").status_code == 403
|
||||
with client._session_factory() as s:
|
||||
assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED"
|
||||
assert len([e for e in s.scalars(select(AuditEvent)) if e.action == "RTC_DENIED"]) == 5
|
||||
# read-only modes stay available to a normal collaborator, and the page tells them why the rest is disabled
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="DRY_RUN").status_code == 303
|
||||
page = client.get(f"/agents/{agent_id}/rtc").text
|
||||
assert "Somente leitura para o seu perfil" in page and "disabled" in page
|
||||
|
||||
|
||||
def test_a_normal_collaborator_cannot_switch_off_a_privileged_mode_either():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as s:
|
||||
s.scalars(select(Installation)).one().rtc_write_mode = "ENABLED"
|
||||
s.commit()
|
||||
_become(client, "PADRAO")
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="DISABLED").status_code == 403
|
||||
with client._session_factory() as s:
|
||||
assert s.scalars(select(Installation)).one().rtc_write_mode == "ENABLED"
|
||||
|
||||
|
||||
def test_gerenciador_admin_can_change_modes_and_the_operator_is_recorded():
|
||||
client, agent_id, _ = _panel_client()
|
||||
_become(client, "ADMIN")
|
||||
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="ENABLED", confirm="PREENCHER").status_code == 303
|
||||
with client._session_factory() as s:
|
||||
assert s.scalars(select(Installation)).one().rtc_write_mode == "ENABLED"
|
||||
event = [e for e in s.scalars(select(AuditEvent)) if e.action == "RTC_WRITE_MODE"][-1]
|
||||
assert event.context["to"] == "ENABLED" and event.context["role"] == "ADMIN" and event.context["operator"]
|
||||
@@ -0,0 +1,235 @@
|
||||
"""SSO from GTI Gerenciador into the Hub (central/app/sso.py, web.py)."""
|
||||
import base64
|
||||
import dataclasses
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config, sso
|
||||
from central.app.human_auth import verify_password
|
||||
from central.app.main import create_app
|
||||
from central.app.models import AdminUser, AuditEvent, SsoUsedAssertion, WebSession
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
START_URL = "https://gtigerenciador.gtigrupo.com.br/sso/fiscal"
|
||||
PRIVATE = Ed25519PrivateKey.generate()
|
||||
PUBLIC_B64 = base64.urlsafe_b64encode(PRIVATE.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode().rstrip("=")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def sso_settings(monkeypatch):
|
||||
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, sso_public_key=PUBLIC_B64, sso_start_url=START_URL, web_cookie_secure=True, federated_session_seconds=3600))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub():
|
||||
factory = setup_db()
|
||||
seed(factory) # creates the LOCAL break-glass admin "operator"
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None), base_url="https://testserver")
|
||||
return client, factory
|
||||
|
||||
|
||||
def assertion(state, *, sub="funcionario:7", key=PRIVATE, algorithm="EdDSA", aud=sso.AUDIENCE, iss=sso.ISSUER, iat=None, lifetime=60, jti=None, **extra):
|
||||
now = int(iat if iat is not None else time.time())
|
||||
claims = {"iss": iss, "aud": aud, "sub": sub, "iat": now, "nbf": now, "exp": now + lifetime, "jti": jti or str(uuid.uuid4()),
|
||||
"nonce": state, "login": "maria", "name": "Maria Teste", "perfil": "PADRAO", **extra}
|
||||
return jwt.encode(claims, key, algorithm=algorithm)
|
||||
|
||||
|
||||
def start(client, next_path="/"):
|
||||
response = client.get("/login", params={"next": next_path}, follow_redirects=False)
|
||||
return response, response.cookies.get(sso.STATE_COOKIE) or client.cookies.get(sso.STATE_COOKIE)
|
||||
|
||||
|
||||
def callback(client, token, next_path="/"):
|
||||
return client.post("/auth/sso/callback", data={"assertion": token, "next": next_path}, follow_redirects=False)
|
||||
|
||||
|
||||
def audits(factory, action):
|
||||
with factory() as session:
|
||||
return session.scalars(select(AuditEvent).where(AuditEvent.action == action)).all()
|
||||
|
||||
|
||||
def test_login_starts_sso_at_the_panel_with_a_browser_bound_state(hub):
|
||||
client, _ = hub
|
||||
response, state = start(client, "/agents")
|
||||
assert response.status_code == 303
|
||||
target = urlsplit(response.headers["location"])
|
||||
assert f"{target.scheme}://{target.netloc}{target.path}" == START_URL
|
||||
query = parse_qs(target.query)
|
||||
assert query["next"] == ["/agents"] and query["state"] == [state] and len(state) >= 16
|
||||
cookie = next(h for h in response.headers.get_list("set-cookie") if h.startswith(sso.STATE_COOKIE))
|
||||
assert "HttpOnly" in cookie and "Secure" in cookie and "SameSite=none" in cookie and "Path=/auth/sso" in cookie
|
||||
|
||||
|
||||
def test_active_employee_enters_without_password_and_is_provisioned(hub):
|
||||
client, factory = hub
|
||||
_, state = start(client, "/agents")
|
||||
response = callback(client, assertion(state), "/agents")
|
||||
assert response.status_code == 303 and response.headers["location"] == "/agents"
|
||||
cookies = response.headers.get_list("set-cookie")
|
||||
session_cookie = next(h for h in cookies if h.startswith(config.settings.web_cookie_name))
|
||||
assert "HttpOnly" in session_cookie and "Secure" in session_cookie and "SameSite=lax" in session_cookie
|
||||
csrf_cookie = next(h for h in cookies if h.startswith("gti_csrf"))
|
||||
assert "Secure" in csrf_cookie and "HttpOnly" not in csrf_cookie
|
||||
page = client.get("/agents")
|
||||
assert page.status_code == 200 and "Maria Teste" in page.text and 'name="password"' not in page.text
|
||||
with factory() as session:
|
||||
user = session.scalar(select(AdminUser).where(AdminUser.external_subject == "funcionario:7"))
|
||||
assert user.auth_source == "GTI_GERENCIADOR" and user.password_hash is None and user.external_role == "PADRAO"
|
||||
web_session = session.scalar(select(WebSession).where(WebSession.admin_user_id == user.id))
|
||||
lifetime = web_session.expires_at.replace(tzinfo=timezone.utc) - datetime.now(timezone.utc)
|
||||
assert timedelta(minutes=55) < lifetime <= timedelta(hours=1)
|
||||
success = audits(factory, "SSO_LOGIN_SUCCESS")
|
||||
assert len(success) == 1 and success[0].target_id == "funcionario:7" and "ip" in success[0].context
|
||||
|
||||
|
||||
def test_second_login_reuses_the_provisioned_user_and_refreshes_its_name(hub):
|
||||
client, factory = hub
|
||||
_, state = start(client)
|
||||
callback(client, assertion(state))
|
||||
_, state = start(client)
|
||||
callback(client, assertion(state, name="Maria T. Souza", perfil="ADMIN"))
|
||||
with factory() as session:
|
||||
users = session.scalars(select(AdminUser).where(AdminUser.auth_source == "GTI_GERENCIADOR")).all()
|
||||
assert len(users) == 1 and users[0].display_name == "Maria T. Souza" and users[0].external_role == "ADMIN"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("builder,reason", [
|
||||
(lambda s: assertion(s, iat=time.time() - 600), "EXPIRED"),
|
||||
(lambda s: assertion(s, key=Ed25519PrivateKey.generate()), "INVALID_SIGNATURE"),
|
||||
(lambda s: assertion(s, aud="outro-sistema"), "WRONG_AUDIENCE"),
|
||||
(lambda s: assertion(s, iss="impostor"), "WRONG_ISSUER"),
|
||||
(lambda s: jwt.encode({"iss": sso.ISSUER, "aud": sso.AUDIENCE, "sub": "funcionario:7", "nonce": s}, "segredo", algorithm="HS256"), "INVALID_ALGORITHM"),
|
||||
(lambda s: assertion(s, lifetime=3600), "LIFETIME_TOO_LONG"),
|
||||
(lambda s: assertion(s, sub="admin"), "INVALID_SUBJECT"),
|
||||
(lambda s: assertion("outro-navegador-0123456789"), "STATE_MISMATCH"),
|
||||
(lambda s: "nao-e-um-jwt", "MALFORMED"),
|
||||
])
|
||||
def test_invalid_assertions_are_rejected_and_audited(hub, builder, reason):
|
||||
client, factory = hub
|
||||
_, state = start(client)
|
||||
token = builder(state)
|
||||
response = callback(client, token)
|
||||
assert response.status_code == 401 and config.settings.web_cookie_name not in response.cookies
|
||||
rejected = audits(factory, "SSO_LOGIN_REJECTED")
|
||||
assert [event.context["reason"] for event in rejected] == [reason]
|
||||
assert token not in str(rejected[0].context)
|
||||
with factory() as session:
|
||||
assert session.scalar(select(AdminUser).where(AdminUser.auth_source == "GTI_GERENCIADOR")) is None
|
||||
|
||||
|
||||
def test_assertion_without_the_state_cookie_is_rejected(hub):
|
||||
client, factory = hub
|
||||
_, state = start(client)
|
||||
client.cookies.delete(sso.STATE_COOKIE)
|
||||
assert callback(client, assertion(state)).status_code == 401
|
||||
assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "STATE_MISMATCH"
|
||||
|
||||
|
||||
def test_replayed_assertion_is_rejected(hub):
|
||||
client, factory = hub
|
||||
_, state = start(client)
|
||||
token = assertion(state)
|
||||
assert callback(client, token).status_code == 303
|
||||
client.cookies.clear()
|
||||
client.cookies.set(sso.STATE_COOKIE, state) # an attacker replaying with the matching state
|
||||
assert callback(client, token).status_code == 401
|
||||
assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "REPLAY"
|
||||
with factory() as session:
|
||||
assert len(session.scalars(select(SsoUsedAssertion)).all()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("malicious", ["//evil.com/x", "https://evil.com", "javascript:alert(1)", "/\\evil.com", "data:text/html,x", "agents"])
|
||||
def test_next_never_leaves_the_hub(hub, malicious):
|
||||
client, _ = hub
|
||||
_, state = start(client)
|
||||
response = callback(client, assertion(state), malicious)
|
||||
assert response.status_code == 303 and response.headers["location"] == "/"
|
||||
login = client.get("/login", params={"next": malicious}, follow_redirects=False)
|
||||
assert parse_qs(urlsplit(login.headers["location"]).query)["next"] == ["/"]
|
||||
|
||||
|
||||
def test_user_disabled_in_the_hub_cannot_sso(hub):
|
||||
client, factory = hub
|
||||
_, state = start(client)
|
||||
callback(client, assertion(state))
|
||||
with factory() as session:
|
||||
user = session.scalar(select(AdminUser).where(AdminUser.external_subject == "funcionario:7"))
|
||||
user.is_active = False; session.commit()
|
||||
client.cookies.clear()
|
||||
_, state = start(client)
|
||||
assert callback(client, assertion(state)).status_code == 401
|
||||
assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "DISABLED_IN_FISCAL"
|
||||
assert client.get("/", follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_federated_account_has_no_password_login(hub):
|
||||
client, _ = hub
|
||||
_, state = start(client)
|
||||
callback(client, assertion(state))
|
||||
client.cookies.clear()
|
||||
response = client.post("/login", data={"username": "gerenciador:7", "password": "qualquer-coisa-longa"}, follow_redirects=False)
|
||||
assert response.status_code == 401
|
||||
assert verify_password("x", None) is False
|
||||
|
||||
|
||||
def test_break_glass_local_admin_still_works_and_is_audited(hub):
|
||||
client, factory = hub
|
||||
assert client.get("/login/local").status_code == 200
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert client.get("/").status_code == 200
|
||||
assert len(audits(factory, "LOGIN_LOCAL_SUCCESS")) == 1
|
||||
client.cookies.clear()
|
||||
client.post("/login", data={"username": "operator", "password": "errada-mas-longa"})
|
||||
assert len(audits(factory, "LOGIN_LOCAL_FAILURE")) == 1
|
||||
|
||||
|
||||
def test_unauthenticated_page_goes_to_sso_and_keeps_the_destination(hub):
|
||||
client, _ = hub
|
||||
response = client.get("/blockers?status=open", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert parse_qs(urlsplit(response.headers["location"]).query)["next"] == ["/blockers?status=open"]
|
||||
|
||||
|
||||
def test_logout_ends_only_the_hub_session_and_csrf_is_enforced(hub):
|
||||
client, factory = hub
|
||||
_, state = start(client)
|
||||
callback(client, assertion(state))
|
||||
assert client.post("/logout", data={}).status_code == 403 # no CSRF token
|
||||
assert client.get("/").status_code == 200
|
||||
response = client.post("/logout", data={"csrf_token": client.cookies.get("gti_csrf")})
|
||||
assert response.status_code == 200 and "Você saiu do GTI Fiscal" in response.text and "gtigerenciador.gtigrupo.com.br" in response.text
|
||||
assert client.get("/", follow_redirects=False).status_code == 303
|
||||
assert len(audits(factory, "LOGOUT")) == 1
|
||||
_, state = start(client) # coming back through the panel signs in again, no password
|
||||
assert callback(client, assertion(state)).status_code == 303
|
||||
|
||||
|
||||
def test_expired_federated_session_sends_back_through_sso(hub):
|
||||
client, factory = hub
|
||||
_, state = start(client)
|
||||
callback(client, assertion(state))
|
||||
with factory() as session:
|
||||
for row in session.scalars(select(WebSession)).all():
|
||||
row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
session.commit()
|
||||
response = client.get("/jobs", follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"].startswith("/login?")
|
||||
|
||||
|
||||
def test_without_sso_configuration_the_hub_keeps_the_local_login(hub, monkeypatch):
|
||||
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, sso_public_key=""))
|
||||
client, factory = hub
|
||||
assert client.get("/login").status_code == 200
|
||||
assert callback(client, assertion("x" * 20)).status_code == 401
|
||||
assert audits(factory, "SSO_LOGIN_REJECTED")[0].context["reason"] == "SSO_DISABLED"
|
||||
@@ -0,0 +1,29 @@
|
||||
from io import BytesIO
|
||||
|
||||
import openpyxl
|
||||
|
||||
|
||||
def test_tipi_parser_requires_official_columns_and_reads_ncm_rows():
|
||||
from central.app.datasets.tipi import parse_tipi_xlsx
|
||||
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.append(["NCM", "EX", "DESCRIÇÃO", "ALÍQUOTA (%)"])
|
||||
sheet.append(["0101.21.00", None, "Reprodutores", "0"])
|
||||
raw = BytesIO(); workbook.save(raw)
|
||||
|
||||
payload = parse_tipi_xlsx(raw.getvalue())
|
||||
|
||||
assert payload.entries[0].ncm == "01012100"
|
||||
assert payload.entries[0].rate == "0"
|
||||
|
||||
|
||||
def test_tipi_parser_preserves_ex_in_the_ncm_key():
|
||||
from central.app.datasets.tipi import parse_tipi_xlsx
|
||||
workbook = openpyxl.Workbook(); sheet = workbook.active
|
||||
sheet.append(["NCM", "EX", "DESCRIÇÃO", "ALÍQUOTA (%)"])
|
||||
sheet.append(["0101.21.00", "01", "Com EX", "NT"])
|
||||
raw = BytesIO(); workbook.save(raw)
|
||||
entry = parse_tipi_xlsx(raw.getvalue()).entries[0]
|
||||
assert entry.ex == "01"
|
||||
assert entry.rate == "NT"
|
||||
@@ -0,0 +1,240 @@
|
||||
from datetime import date
|
||||
|
||||
from central.app.operational_state import classify_product_state, summarize_operational_states
|
||||
from central.app.unified_fiscal_coverage import (blocker_category, canonical_cst, evaluate_product, incidence_kind, legal_effective_context, pis_cofins_applicability, rtc_enabled,
|
||||
validate_cclasstrib, validate_cst)
|
||||
|
||||
CATALOG = {"classes": {"000001": "000", "200003": "200", "410004": "410", "999999": None, "555555": "555"}, "cst": {"000", "200", "410"}, "duplicates": 0}
|
||||
ON = {"enabled": True, "catalog": CATALOG, "catalog_valid": True}
|
||||
OFF = {"enabled": False, "catalog": CATALOG, "catalog_valid": True}
|
||||
SIMPLES, NORMAL = {"crt": "1"}, {"crt": "3"}
|
||||
EMPTY = {"CODIGO": "1", "PISCOFINS": "N", "INCIDENCIA_PISCOFINS": "NENHUMA"}
|
||||
FIELDS = ("COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS")
|
||||
|
||||
|
||||
def kinds(result):
|
||||
return {b["type"] for b in result["blockers"]}
|
||||
|
||||
|
||||
def warns(result):
|
||||
return {w["type"] for w in result["warnings"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- RTC on / off
|
||||
def test_rtc_off_ignores_cclasstrib_completely():
|
||||
for value in (None, "", "12x", "999999", "000001"):
|
||||
r = evaluate_product({**EMPTY, "CCLASSTRIB": value}, SIMPLES, OFF)
|
||||
assert r["blockers"] == [] and r["applicability"]["RTC"] == "NOT_APPLICABLE" and "CCLASSTRIB" not in r["mandatory_fields"]
|
||||
assert evaluate_product(EMPTY, SIMPLES, None)["blockers"] == []
|
||||
|
||||
|
||||
def test_rtc_on_requires_and_validates_cclasstrib():
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, NORMAL, ON)) == {"CCLASSTRIB_MISSING"}
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": "12x"}, NORMAL, ON)) == {"CCLASSTRIB_INVALID_FORMAT"}
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": "123456"}, NORMAL, ON)) == {"CCLASSTRIB_NOT_IN_CATALOG"}
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": "999999"}, NORMAL, ON)) == {"RTC_CST_MISSING"} # class without CST
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": "555555"}, NORMAL, ON)) == {"RTC_CST_MISSING"} # class points to a CST that is not in RTC_CST (orphan)
|
||||
ok = evaluate_product({**EMPTY, "CCLASSTRIB": "200003"}, NORMAL, ON)
|
||||
assert ok["blockers"] == [] and ok["applicability"]["RTC"] == "REQUIRED" and "CCLASSTRIB" in ok["mandatory_fields"]
|
||||
|
||||
|
||||
def test_rtc_on_with_invalid_catalog_blocks_with_a_single_clear_reason():
|
||||
r = evaluate_product({**EMPTY, "CCLASSTRIB": "200003"}, NORMAL, {"enabled": True, "catalog": CATALOG, "catalog_valid": False})
|
||||
assert kinds(r) == {"RTC_CATALOG_INVALID"}
|
||||
|
||||
|
||||
def test_rtc_vigencia_is_used_only_when_the_catalog_carries_it():
|
||||
cat = {**CATALOG, "validity": {"200003": (date(2027, 1, 1), None), "410004": (date(2025, 1, 1), date(2025, 12, 31))}}
|
||||
assert validate_cclasstrib("200003", cat, date(2026, 9, 24)) == "EXPIRED" and validate_cclasstrib("410004", cat, date(2026, 9, 24)) == "EXPIRED"
|
||||
assert validate_cclasstrib("000001", cat, date(2026, 9, 24)) == "VALID" and validate_cclasstrib("200003", CATALOG, date(2026, 9, 24)) == "VALID"
|
||||
|
||||
|
||||
def test_rtc_enabled_needs_schema_and_erp_switch():
|
||||
caps = {"HAS_PRODUCT_CCLASSTRIB": True, "HAS_RTC_RELATIONSHIP": True, "RTC_ENABLED_BY_PARAM": True}
|
||||
assert rtc_enabled(caps) and not rtc_enabled({**caps, "RTC_ENABLED_BY_PARAM": False}) and not rtc_enabled({**caps, "HAS_RTC_RELATIONSHIP": False}) and not rtc_enabled({})
|
||||
|
||||
|
||||
def test_cclasstrib_is_not_applicable_for_simples_before_its_effective_date_even_when_rtc_is_on():
|
||||
result = evaluate_product({**EMPTY, "CCLASSTRIB": None}, SIMPLES, ON, date(2026, 9, 25))
|
||||
|
||||
assert result["applicability"]["RTC"] == "NOT_APPLICABLE"
|
||||
assert result["basis"]["RTC"] == "SIMPLES_CCLASSTRIB_NOT_EFFECTIVE"
|
||||
assert "CCLASSTRIB" not in result["mandatory_fields"]
|
||||
assert not [blocker for blocker in result["blockers"] if blocker["category"] == "RTC"]
|
||||
|
||||
|
||||
def test_not_applicable_cclasstrib_ignores_any_existing_value():
|
||||
for value in (None, "", "12x", "999999", "200003"):
|
||||
result = evaluate_product({**EMPTY, "CCLASSTRIB": value}, SIMPLES, ON, date(2026, 9, 25))
|
||||
assert result["applicability"]["RTC"] == "NOT_APPLICABLE"
|
||||
assert "CCLASSTRIB" not in result["mandatory_fields"]
|
||||
assert not [blocker for blocker in result["blockers"] if blocker["category"] == "RTC"]
|
||||
|
||||
|
||||
def test_cclasstrib_becomes_applicable_on_each_regime_effective_date():
|
||||
for crt in ("1", "2", "4"):
|
||||
before = evaluate_product({**EMPTY, "CCLASSTRIB": None}, {"crt": crt}, ON, date(2026, 12, 31))
|
||||
effective = evaluate_product({**EMPTY, "CCLASSTRIB": None}, {"crt": crt}, ON, date(2027, 1, 1))
|
||||
assert before["applicability"]["RTC"] == "NOT_APPLICABLE"
|
||||
assert "CCLASSTRIB_MISSING" in kinds(effective)
|
||||
|
||||
normal_before = evaluate_product({**EMPTY, "CCLASSTRIB": None}, NORMAL, ON, date(2026, 8, 2))
|
||||
normal_effective = evaluate_product({**EMPTY, "CCLASSTRIB": None}, NORMAL, ON, date(2026, 8, 3))
|
||||
assert normal_before["applicability"]["RTC"] == "NOT_APPLICABLE"
|
||||
assert "CCLASSTRIB_MISSING" in kinds(normal_effective)
|
||||
|
||||
|
||||
def test_cclasstrib_is_not_applicable_when_company_regime_is_unresolved():
|
||||
for company in (None, {"crt": None}, {"crt": "9"}):
|
||||
result = evaluate_product({**EMPTY, "CCLASSTRIB": None}, company, ON, date(2026, 9, 25))
|
||||
assert result["applicability"]["RTC"] == "NOT_APPLICABLE"
|
||||
assert not [blocker for blocker in result["blockers"] if blocker["category"] == "RTC"]
|
||||
|
||||
|
||||
def test_non_applicable_cclasstrib_does_not_mask_other_fiscal_blockers():
|
||||
product = {**EMPTY, "CCLASSTRIB": None, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "MONOFÁSICA"}
|
||||
result = evaluate_product(product, SIMPLES, ON, date(2026, 9, 25))
|
||||
|
||||
assert kinds(result) == {"PIS_CST_MISSING", "COFINS_CST_MISSING"}
|
||||
|
||||
|
||||
def test_legal_context_is_informational_and_never_creates_a_blocker():
|
||||
simples, normal = legal_effective_context("1"), legal_effective_context("3")
|
||||
assert simples["informational_only"] and simples["ibs_cbs_in_documents"] == "MANDATORY_FROM_2027-01-01" and normal["ibs_cbs_in_documents"] == "MANDATORY_FROM_2026-08-03"
|
||||
assert simples["cclasstrib_orientation"] == "PENDING_OFFICIAL_NT" and any("Ato Conjunto RFB/CGIBS n 4/2026" in x for x in simples["sources"])
|
||||
assert legal_effective_context(None)["ibs_cbs_in_documents"] == "UNKNOWN_REGIME"
|
||||
# Global RTC support does not make Simples cClassTrib mandatory before its effective date.
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, SIMPLES, ON, date(2026, 9, 25))) == set()
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, NORMAL, ON, date(2026, 9, 25))) == {"CCLASSTRIB_MISSING"}
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, SIMPLES, OFF, date(2026, 9, 25))) == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- PIS / COFINS applicability is NOT circular
|
||||
def test_applicability_never_depends_on_whether_the_six_fields_are_empty():
|
||||
typed = {f: "49" if "COD" in f else "0" for f in FIELDS}
|
||||
for crt in ("1", "2", "4", "3", None):
|
||||
for base in (EMPTY, {"CODIGO": "1", "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "MONOFÁSICA"}, {"CODIGO": "1", "PISCOFINS": "", "INCIDENCIA_PISCOFINS": ""}):
|
||||
empty_state = pis_cofins_applicability(base, crt)
|
||||
filled_state = pis_cofins_applicability({**base, **typed}, crt)
|
||||
assert empty_state == filled_state, (crt, base) # same verdict with the fields empty or typed: emptiness proves nothing
|
||||
|
||||
|
||||
def test_empty_fields_with_applicable_context_is_a_missing_blocker():
|
||||
for crt in ("1", "3"):
|
||||
r = evaluate_product({**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "MONOFÁSICA"}, {"crt": crt}, OFF)
|
||||
assert r["applicability"]["PIS_COFINS"] == "REQUIRED" and kinds(r) == {"PIS_CST_MISSING", "COFINS_CST_MISSING"}
|
||||
|
||||
|
||||
def test_empty_fields_with_provably_not_applicable_context_is_not_applicable():
|
||||
for crt in ("1", "2", "4"):
|
||||
r = evaluate_product(EMPTY, {"crt": crt}, OFF)
|
||||
assert r["applicability"]["PIS_COFINS"] == "NOT_APPLICABLE" and r["basis"]["PIS_COFINS"] == "SIMPLES_DAS_NO_SPECIAL_TREATMENT" and r["blockers"] == [] and not r["needs_review"]
|
||||
assert evaluate_product({"CODIGO": "1"}, SIMPLES, OFF)["blockers"] == [] # columns absent in an old schema, Simples: nothing to require
|
||||
|
||||
|
||||
def test_empty_fields_with_insufficient_context_need_review_and_never_default_to_complete():
|
||||
for company in (NORMAL, {"crt": None}, None):
|
||||
r = evaluate_product({"CODIGO": "1", "PISCOFINS": "", "INCIDENCIA_PISCOFINS": ""}, company, OFF)
|
||||
assert r["applicability"]["PIS_COFINS"] == "NEEDS_REVIEW" and r["needs_review"] and r["blockers"] == []
|
||||
state = classify_product_state({"CODIGO": "1"}, {"status": "EXACT_NCM"}, fiscal=r)["state"]
|
||||
assert state == "NEEDS_REVIEW" # counted as UNCLASSIFIED, never inflating coverage
|
||||
|
||||
|
||||
def test_taxed_regime_with_explicit_no_special_treatment_is_optional_and_typed_values_must_be_valid():
|
||||
r = evaluate_product(EMPTY, NORMAL, OFF)
|
||||
assert r["applicability"]["PIS_COFINS"] == "OPTIONAL" and r["blockers"] == []
|
||||
assert kinds(evaluate_product({**EMPTY, "COD_SAIDA_PIS": "2x"}, NORMAL, OFF)) == {"PIS_CST_INVALID"}
|
||||
|
||||
|
||||
def test_unknown_incidence_text_needs_review_in_every_regime():
|
||||
for crt in ("1", "3"):
|
||||
r = evaluate_product({**EMPTY, "INCIDENCIA_PISCOFINS": "OUTRA COISA"}, {"crt": crt}, OFF)
|
||||
assert r["applicability"]["PIS_COFINS"] == "NEEDS_REVIEW" and r["needs_review"] and r["blockers"] == []
|
||||
|
||||
|
||||
def test_simples_typed_garbage_is_ignored_as_a_warning_not_a_blocker():
|
||||
row = {**EMPTY, "COD_ENTRADA_PIS": "2", "COD_SAIDA_PIS": "2", "ALIQ_PIS": "2", "COD_ENTRADA_COFINS": "2", "COD_SAIDA_COFINS": "2", "ALIQ_COFINS": "2"}
|
||||
r = evaluate_product(row, SIMPLES, OFF)
|
||||
assert r["applicability"]["PIS_COFINS"] == "NOT_APPLICABLE" and r["blockers"] == []
|
||||
assert warns(r) == {"CST_PIS_NON_CANONICAL_FORMAT", "CST_COFINS_NON_CANONICAL_FORMAT"} # "2" is one digit of official "02": normalisable
|
||||
bad = evaluate_product({**EMPTY, "COD_ENTRADA_COFINS": "000", "COD_SAIDA_COFINS": "060"}, SIMPLES, OFF)
|
||||
assert bad["blockers"] == [] and warns(bad) == {"CST_COFINS_INVALID_IGNORED"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- CST normalisation
|
||||
def test_canonical_cst_accepts_only_one_digit_with_an_official_zero_padded_form():
|
||||
assert canonical_cst("4") == "04" and canonical_cst("04") == "04" and canonical_cst(" 4 ") == "04" and canonical_cst("9") == "09" and canonical_cst("1") == "01"
|
||||
for text in ("0", "049", "000", "060", "2x", "x", "", None, "10", "12", "00"):
|
||||
assert canonical_cst(text) is None, text
|
||||
assert validate_cst("4") == "VALID_AFTER_NORMALIZATION" and validate_cst("04") == "VALID" and validate_cst("049") == "INVALID" and validate_cst("") == "EMPTY"
|
||||
|
||||
|
||||
def test_unpadded_cst_is_semantically_valid_warns_and_is_used_canonically_for_the_rules():
|
||||
row = {**EMPTY, "COD_SAIDA_PIS": "4", "COD_SAIDA_COFINS": "4"}
|
||||
r = evaluate_product(row, NORMAL, OFF)
|
||||
assert r["blockers"] == [] and warns(r) == {"CST_PIS_NON_CANONICAL_FORMAT", "CST_COFINS_NON_CANONICAL_FORMAT"}
|
||||
mono = {**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "MONOFÁSICA", "COD_SAIDA_PIS": "4", "COD_SAIDA_COFINS": "4"}
|
||||
assert kinds(evaluate_product(mono, NORMAL, OFF)) == set() # "4" -> "04" is compatible with MONOFASICA
|
||||
assert kinds(evaluate_product({**mono, "COD_SAIDA_PIS": "1", "ALIQ_PIS": "1,65"}, NORMAL, OFF)) == {"PIS_CST_INCIDENCE_MISMATCH"} # "1" -> "01" is not
|
||||
assert kinds(evaluate_product({**EMPTY, "COD_SAIDA_PIS": "049"}, NORMAL, OFF)) == {"PIS_CST_INVALID"} # ambiguous: still a blocker
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- PIS / COFINS rules
|
||||
def test_declared_monophasic_product_requires_a_valid_exit_cst():
|
||||
good = {**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "MONOFÁSICA", "COD_SAIDA_PIS": "04", "COD_SAIDA_COFINS": "04"}
|
||||
r = evaluate_product(good, SIMPLES, OFF)
|
||||
assert r["blockers"] == [] and r["applicability"]["PIS_COFINS"] == "REQUIRED" and set(r["mandatory_fields"]) == {"COD_SAIDA_PIS", "COD_SAIDA_COFINS"}
|
||||
assert kinds(evaluate_product({**good, "COD_SAIDA_PIS": None}, SIMPLES, OFF)) == {"PIS_CST_MISSING"}
|
||||
assert kinds(evaluate_product({**good, "COD_SAIDA_COFINS": "01", "ALIQ_COFINS": "7,6"}, SIMPLES, OFF)) == {"COFINS_CST_INCIDENCE_MISMATCH"}
|
||||
|
||||
|
||||
def test_st_incidence_with_taxable_cst_is_a_mismatch_and_pis_cofins_are_independent():
|
||||
row = {**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "SUBST.TRIBUTÁRIA", "COD_ENTRADA_PIS": "73", "COD_SAIDA_PIS": "01", "ALIQ_PIS": "0,65", "COD_ENTRADA_COFINS": "73", "COD_SAIDA_COFINS": "05"}
|
||||
assert kinds(evaluate_product(row, NORMAL, OFF)) == {"PIS_CST_INCIDENCE_MISMATCH"} # only PIS is wrong; COFINS 05 is right
|
||||
|
||||
|
||||
def test_rate_rules_follow_the_cst():
|
||||
base = {**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "NENHUMA"}
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "01", "COD_SAIDA_COFINS": "01"}, NORMAL, OFF)) == {"PIS_RATE_MISSING", "COFINS_RATE_MISSING"}
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "01", "ALIQ_PIS": "1,65", "COD_SAIDA_COFINS": "01", "ALIQ_COFINS": "7,6"}, NORMAL, OFF)) == set()
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "49", "COD_SAIDA_COFINS": "49"}, SIMPLES, OFF)) == set()
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "49", "ALIQ_PIS": "abc", "COD_SAIDA_COFINS": "49"}, SIMPLES, OFF)) == {"PIS_RATE_INVALID"}
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "49", "ALIQ_PIS": "150", "COD_SAIDA_COFINS": "49"}, SIMPLES, OFF)) == {"PIS_RATE_INVALID"}
|
||||
|
||||
|
||||
def test_inconsistent_flags():
|
||||
assert "PIS_COFINS_FLAG_INCONSISTENT" in kinds(evaluate_product({**EMPTY, "PISCOFINS": "N", "INCIDENCIA_PISCOFINS": "MONOFÁSICA", "COD_SAIDA_PIS": "04", "COD_SAIDA_COFINS": "04"}, SIMPLES, OFF))
|
||||
assert "PIS_COFINS_FLAG_INCONSISTENT" in kinds(evaluate_product({**EMPTY, "PISCOFINS": "S"}, SIMPLES, OFF))
|
||||
|
||||
|
||||
def test_incidence_text_is_encoding_and_accent_tolerant():
|
||||
for text in ("MONOFÁSICA", "MONOFASICA", "MONOF�SICA", "monofásica"):
|
||||
assert incidence_kind(text) == "MONOFASICA"
|
||||
assert incidence_kind("SUBST.TRIBUTÁRIA") == "SUBSTITUICAO" and incidence_kind(None) == "NONE" and incidence_kind(" nenhuma ") == "NONE" and incidence_kind("???") == "UNKNOWN"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ONE definition of complete, ONE queue
|
||||
def test_blocker_categories_feed_the_single_dashboard_breakdown():
|
||||
assert {blocker_category(t) for t in ("PIS_CST_INVALID", "PIS_COFINS_FLAG_INCONSISTENT")} == {"PIS"} and blocker_category("COFINS_RATE_MISSING") == "COFINS"
|
||||
assert {blocker_category(t) for t in ("CCLASSTRIB_MISSING", "RTC_CST_MISSING", "RTC_CATALOG_INVALID")} == {"RTC"} and blocker_category("XYZ") == "OTHER"
|
||||
|
||||
|
||||
def test_unified_coverage_invariant_and_rtc_toggle_changes_only_rtc_products():
|
||||
products = [{**EMPTY, "CODIGO": "1"}, {**EMPTY, "CODIGO": "2", "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "MONOFÁSICA"}, {**EMPTY, "CODIGO": "3", "CCLASSTRIB": "200003"},
|
||||
{**EMPTY, "CODIGO": "4", "INCIDENCIA_PISCOFINS": "???"}]
|
||||
|
||||
def run(rtc):
|
||||
states = [classify_product_state(p, {"status": "EXACT_NCM"}, fiscal=evaluate_product(p, SIMPLES, rtc)) for p in products]
|
||||
return summarize_operational_states(states)
|
||||
off, on = run(OFF), run(ON)
|
||||
for s in (off, on):
|
||||
assert s["TOTAL_PRODUCTS"] == s["COMPLETE_PRODUCTS"] + s["SAFE_BLOCKED"] + s["FAILED_TECHNICAL"] + s["UNCLASSIFIED"] == 4
|
||||
assert off["COMPLETE_PRODUCTS"] == 2 and off["BLOCKED_FISCAL_DATA"] == 1 and off["UNCLASSIFIED"] == 1 # RTC off: cClassTrib never counts
|
||||
assert on["COMPLETE_PRODUCTS"] == 2 and on["BLOCKED_FISCAL_DATA"] == 1 and on["UNCLASSIFIED"] == 1 # RTC on, but Simples cClassTrib is not effective yet
|
||||
|
||||
|
||||
def test_fiscal_blockers_never_hide_or_override_a_decision_blocker():
|
||||
fiscal = evaluate_product({**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "MONOFÁSICA"}, SIMPLES, OFF)
|
||||
state = classify_product_state({"CODIGO": "1"}, {"status": "INVALID_NCM"}, fiscal=fiscal)
|
||||
assert state["state"] == "BLOCKED_MISSING_EVIDENCE" # the NCM blocker stays the primary state
|
||||
assert classify_product_state({"CODIGO": "1"}, {"status": "EXACT_NCM"}, fiscal=None)["state"] == "COMPLETE"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""'Atualização disponível' must compare the Agent with the real default release, semantically."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from central.app.control_plane import dashboard_summary, list_installations
|
||||
from central.app.installation_identity import compute_version_drift, outdated_versions, parse_version, reference_agent_version
|
||||
from central.app.models import AgentRelease
|
||||
from central.tests.test_control_plane import setup_db
|
||||
from central.tests.test_operational_view_models import NOW, _installation
|
||||
|
||||
|
||||
def _release(session, version, status="PUBLISHED", platform="windows"):
|
||||
session.add(AgentRelease(version=version, platform=platform, architecture="x64", filename=f"GTIFiscalAgent-Setup-{version}.exe", storage_path=f"{version}.exe",
|
||||
size_bytes=1, sha256=version.ljust(64, "0")[:64], status=status))
|
||||
session.flush()
|
||||
|
||||
|
||||
def test_versions_compare_semantically_not_as_strings():
|
||||
assert compute_version_drift("1.1.7", "1.1.7") == "CURRENT"
|
||||
assert compute_version_drift("1.1.10", "1.1.9") == "CURRENT" # string compare would say older
|
||||
assert compute_version_drift("1.1.6", "1.1.7") == "OUTDATED"
|
||||
assert compute_version_drift("1.2.0", "1.1.7") == "CURRENT" # ahead of the release is not "outdated"
|
||||
assert compute_version_drift("1.0.0rc1", "1.0.0") == "OUTDATED" # pre-release sorts below its final
|
||||
assert compute_version_drift(None, "1.1.7") == "UNKNOWN"
|
||||
assert parse_version("garbage") is None
|
||||
|
||||
|
||||
def test_reference_is_the_highest_published_release_and_ignores_revoked_and_drafts():
|
||||
with setup_db()() as session:
|
||||
_release(session, "1.1.5", "REVOKED")
|
||||
_release(session, "1.1.6")
|
||||
_release(session, "1.1.7")
|
||||
_release(session, "1.9.9", "REVOKED")
|
||||
_release(session, "2.0.0", "DRAFT")
|
||||
_release(session, "3.0.0", platform="linux")
|
||||
assert reference_agent_version(session) == "1.1.7"
|
||||
|
||||
|
||||
def test_agent_on_the_default_release_is_current_everywhere_even_with_a_stale_snapshot():
|
||||
with setup_db()() as session:
|
||||
_release(session, "1.1.7")
|
||||
_release(session, "1.1.6", "REVOKED")
|
||||
# snapshot was written as OUTDATED by an older heartbeat (compared with a stale reference)
|
||||
_installation(session, 1, version="1.1.7")
|
||||
_installation(session, 2, version="1.1.6")
|
||||
session.commit()
|
||||
assert reference_agent_version(session) == "1.1.7"
|
||||
assert outdated_versions(session, "1.1.7") == ["1.1.6"]
|
||||
rows = {item["agent_version"]: item for item in list_installations(session, now=NOW)["items"]}
|
||||
assert rows["1.1.7"]["version_drift"] == "CURRENT" and rows["1.1.6"]["version_drift"] == "OUTDATED"
|
||||
assert [i["agent_version"] for i in list_installations(session, version="outdated", now=NOW)["items"]] == ["1.1.6"]
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
assert [a["agent_version"] for a in summary["outdated_agents"]] == ["1.1.6"]
|
||||
|
||||
|
||||
def test_a_newer_published_release_makes_the_previous_one_outdated():
|
||||
with setup_db()() as session:
|
||||
_release(session, "1.1.7")
|
||||
_installation(session, 1, version="1.1.7")
|
||||
session.commit()
|
||||
assert list_installations(session, version="outdated", now=NOW)["total"] == 0
|
||||
_release(session, "1.1.8")
|
||||
session.commit()
|
||||
assert list_installations(session, version="outdated", now=NOW)["total"] == 1
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Zero-touch control plane: central scheduler (INITIAL_SYNC once, SYNC_INCREMENTAL
|
||||
per interval), single-job concurrency, central pause/resume, coverage snapshot from
|
||||
the job result, idempotency. Everything is Hub-side — no local toggle involved."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.jobs import claim_next_job, create_job, recover_stale_running
|
||||
from central.app.models import Agent, AgentSnapshot, AuditEvent, Installation, Job
|
||||
from central.tests.test_jobs import _client
|
||||
|
||||
H = {"Authorization": "Bearer test-token"}
|
||||
INST = "00000000-0000-4000-8000-0000000000aa"
|
||||
OPERATIONAL = {"COMPLETE": 480, "CORRECTED": 0, "BLOCKED_MISSING_EVIDENCE": 31, "BLOCKED_CONFLICT": 10, "BLOCKED_COMPANY_CONTEXT": 0, "BLOCKED_SCHEMA": 0,
|
||||
"FAILED_TECHNICAL": 0, "TOTAL_PRODUCTS": 521, "SAFE_BLOCKED": 41}
|
||||
|
||||
|
||||
def hb(client):
|
||||
return client.post("/v1/agents/heartbeat", json={"installation_id": INST, "status": "IDLE", "agent_version": "1.1.3"}, headers=H)
|
||||
|
||||
|
||||
def jobs(engine, kind=None):
|
||||
with Session(engine) as s:
|
||||
q = select(Job).order_by(Job.id)
|
||||
if kind: q = q.where(Job.kind == kind)
|
||||
return s.scalars(q).all()
|
||||
|
||||
|
||||
def finish(client, engine, summary=None):
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
assert job, "expected a claimable job"
|
||||
body = {"result_summary": summary or {"operational": OPERATIONAL, "products_processed": 521, "products_changed": 521, "blocked_products": [], "resolved_products": []}}
|
||||
assert client.post(f"/v1/jobs/{job['job_id']}/complete", json=body, headers=H).status_code == 200
|
||||
return job
|
||||
|
||||
|
||||
def age_last_sync(engine, seconds):
|
||||
with Session(engine) as s:
|
||||
for job in s.scalars(select(Job)).all():
|
||||
job.finished_at = datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
||||
job.created_at = datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_first_heartbeat_creates_exactly_one_initial_sync():
|
||||
client, engine = _client()
|
||||
body = hb(client).json()
|
||||
assert body["processing_enabled"] is True and body["sync_interval_seconds"] == 1800
|
||||
for _ in range(4):
|
||||
hb(client)
|
||||
initial = jobs(engine, "INITIAL_SYNC")
|
||||
assert len(initial) == 1 and initial[0].status == "PENDING" and initial[0].payload["reason"] == "INITIAL_SYNC"
|
||||
|
||||
|
||||
def test_heartbeat_metadata_stores_auto_discovered_company_crt_and_contribuinte_ipi():
|
||||
"""company_crt and company_contribuinte_ipi were never sent at heartbeat before (a real transport gap); once the Agent reports them
|
||||
(auto-discovered from C000004, never guessed), the Hub stores them on Installation - fiscal_completion.build_company reads them directly."""
|
||||
client, engine = _client()
|
||||
client.post("/v1/agents/heartbeat", json={"installation_id": INST, "status": "IDLE", "agent_version": "1.1.3", "company_uf": "BA", "company_crt": "1", "company_contribuinte_ipi": "S"}, headers=H)
|
||||
with Session(engine) as s:
|
||||
inst = s.scalar(select(Installation))
|
||||
assert inst.company_uf == "BA" and inst.company_crt == "1" and inst.company_contribuinte_ipi == "S"
|
||||
|
||||
|
||||
def test_initial_sync_completion_sets_coverage_and_invariant():
|
||||
client, engine = _client()
|
||||
hb(client); finish(client, engine)
|
||||
with Session(engine) as s:
|
||||
snap = s.scalar(select(AgentSnapshot)); inst = s.scalar(select(Installation))
|
||||
assert (snap.total_products, snap.fiscally_complete, snap.safe_blocked, snap.failed_products) == (521, 480, 41, 0)
|
||||
assert snap.fiscally_complete + snap.safe_blocked + snap.failed_products == snap.total_products # coverage invariant closes
|
||||
assert snap.blockers == {"BLOCKED_MISSING_EVIDENCE": 31, "BLOCKED_CONFLICT": 10}
|
||||
assert inst.last_sync_at is not None and inst.last_sync_changed == 521
|
||||
for _ in range(3):
|
||||
hb(client)
|
||||
assert len(jobs(engine)) == 1 # no second initial sync, and not due for an incremental yet
|
||||
|
||||
|
||||
def test_incremental_sync_scheduled_once_per_interval():
|
||||
client, engine = _client()
|
||||
hb(client); finish(client, engine)
|
||||
age_last_sync(engine, 1801)
|
||||
hb(client); hb(client); hb(client)
|
||||
inc = jobs(engine, "SYNC_INCREMENTAL")
|
||||
assert len(inc) == 1 and inc[0].payload["reason"] == "SCHEDULED"
|
||||
assert not jobs(engine, "REPROCESS_ALL") # full reprocess never runs automatically
|
||||
|
||||
|
||||
def test_central_pause_stops_scheduling_and_claiming_but_heartbeat_continues():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
with Session(engine) as s:
|
||||
s.scalar(select(Installation)).processing_enabled = False; s.commit()
|
||||
body = hb(client).json()
|
||||
assert body["processing_enabled"] is False and body["enabled"] is True # heartbeat keeps working
|
||||
assert client.get("/v1/jobs/next", headers=H).json() is None # the PENDING initial sync is NOT handed out while paused
|
||||
with Session(engine) as s:
|
||||
s.scalar(select(Installation)).processing_enabled = True; s.commit()
|
||||
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "INITIAL_SYNC" # resume: jobs flow again
|
||||
|
||||
|
||||
def test_only_one_job_runs_at_a_time_and_queue_is_fifo():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
with Session(engine) as s:
|
||||
agent = s.scalar(select(Agent))
|
||||
create_job(s, agent, "REPROCESS_PRODUCTS", {"codigos": ["1"]})
|
||||
first = client.get("/v1/jobs/next", headers=H).json()
|
||||
assert first["kind"] == "INITIAL_SYNC"
|
||||
assert client.get("/v1/jobs/next", headers=H).json() is None # second job waits: one fiscal job per Firebird
|
||||
client.post(f"/v1/jobs/{first['job_id']}/complete", json={"result_summary": {}}, headers=H)
|
||||
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "REPROCESS_PRODUCTS"
|
||||
|
||||
|
||||
def test_reprocess_all_and_sync_now_are_not_duplicated():
|
||||
client, engine = _client()
|
||||
hb(client); finish(client, engine)
|
||||
with Session(engine) as s:
|
||||
agent = s.scalar(select(Agent))
|
||||
a = create_job(s, agent, "SYNC_INCREMENTAL", {"reason": "MANUAL_SYNC_NOW"}); b = create_job(s, agent, "SYNC_INCREMENTAL")
|
||||
assert a.id == b.id # double click = one job
|
||||
full1 = create_job(s, agent, "REPROCESS_ALL"); full2 = create_job(s, agent, "REPROCESS_ALL")
|
||||
assert full1.id == full2.id and full1.id != a.id # a full reprocess still queues behind the incremental, never twice
|
||||
assert create_job(s, agent, "SYNC_INCREMENTAL").id == a.id
|
||||
|
||||
|
||||
def test_stale_running_job_does_not_block_the_queue_forever():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
with Session(engine) as s:
|
||||
row = s.get(Job, job["job_id"]); row.started_at = datetime.now(timezone.utc) - timedelta(hours=2); s.commit()
|
||||
create_job(s, s.scalar(select(Agent)), "REPROCESS_PRODUCTS", {"codigos": ["1"]})
|
||||
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "REPROCESS_PRODUCTS"
|
||||
with Session(engine) as s:
|
||||
assert s.get(Job, job["job_id"]).status == "FAILED"
|
||||
|
||||
|
||||
def test_job_events_recorded_without_secrets():
|
||||
client, engine = _client()
|
||||
hb(client); finish(client, engine)
|
||||
with Session(engine) as s:
|
||||
from central.app.models import AgentEvent
|
||||
types = {e.event_type for e in s.scalars(select(AgentEvent)).all()}
|
||||
assert {"HEARTBEAT", "JOB_STARTED", "JOB_COMPLETED"} <= types
|
||||
|
||||
|
||||
def test_failed_job_records_failure_and_does_not_reschedule_immediately():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
client.post(f"/v1/jobs/{job['job_id']}/fail", json={"error": "Firebird offline"}, headers=H)
|
||||
for _ in range(3):
|
||||
hb(client)
|
||||
assert len(jobs(engine)) == 1 # backoff = the interval; no error loop against a broken Firebird
|
||||
with Session(engine) as s:
|
||||
assert s.scalar(select(AgentSnapshot)).last_job_status == "FAILED"
|
||||
|
||||
|
||||
# ---------- panel: central control buttons + status ----------
|
||||
|
||||
def _panel():
|
||||
from central.tests.test_control_panel_pages import _panel_client
|
||||
client, agent_id, _ = _panel_client()
|
||||
return client, agent_id, client._session_factory
|
||||
|
||||
|
||||
def test_panel_shows_processing_status_and_the_operational_buttons():
|
||||
client, agent_id, _ = _panel()
|
||||
body = client.get(f"/agents/{agent_id}").text
|
||||
for label in ("Sincronizar agora", "Reprocessar tudo", "Pausar processamento", "Última sincronização", "Sincronização agendada"):
|
||||
assert label in body
|
||||
assert "Ativo" in body and "a cada 30 min" in body and "Retomar processamento" not in body
|
||||
|
||||
|
||||
def test_panel_sync_now_pause_resume_are_csrf_protected_audited_and_idempotent():
|
||||
client, agent_id, factory = _panel()
|
||||
csrf = client.cookies.get("gti_csrf")
|
||||
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": "wrong"}).status_code == 403
|
||||
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": csrf}, follow_redirects=False).status_code == 303
|
||||
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": csrf}, follow_redirects=False).status_code == 303 # double click
|
||||
with factory() as s:
|
||||
incs = s.scalars(select(Job).where(Job.kind == "SYNC_INCREMENTAL")).all()
|
||||
assert len(incs) == 1 and incs[0].payload["reason"] == "MANUAL_SYNC_NOW"
|
||||
|
||||
client.post(f"/agents/{agent_id}/pause", data={"csrf_token": csrf})
|
||||
with factory() as s:
|
||||
assert s.scalar(select(Installation).where(Installation.agent_id == agent_id)).processing_enabled is False
|
||||
paused = client.get(f"/agents/{agent_id}").text
|
||||
assert "Pausado" in paused and "Retomar processamento" in paused and "disabled" in paused # Sync-now disabled while paused
|
||||
client.post(f"/agents/{agent_id}/resume", data={"csrf_token": csrf})
|
||||
with factory() as s:
|
||||
assert s.scalar(select(Installation).where(Installation.agent_id == agent_id)).processing_enabled is True
|
||||
actions = [a.action for a in s.scalars(select(AuditEvent)).all()]
|
||||
assert {"SYNC_NOW", "PROCESSING_PAUSE", "PROCESSING_RESUME"} <= set(actions)
|
||||
|
||||
|
||||
def test_panel_controls_require_login():
|
||||
from fastapi.testclient import TestClient
|
||||
from central.app.main import create_app
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
factory = setup_db(); agent_id, _ = seed(factory)
|
||||
anon = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
for action in ("sync-now", "pause", "resume"):
|
||||
assert anon.post(f"/agents/{agent_id}/{action}", data={}, follow_redirects=False).status_code == 303 # bounced to login, nothing executed
|
||||
|
||||
|
||||
def _fiscal_blocker(code, field, reason, fp):
|
||||
return {"company": "", "product_code": code, "gtin": None, "description": "P" + code, "blocker_type": "BLOCKED_FISCAL_DATA", "field": field, "reason_code": reason, "evidence_summary": {}, "input_fingerprint": fp}
|
||||
|
||||
|
||||
def test_fiscal_blockers_share_the_single_queue_and_are_resolved_by_the_next_full_pass():
|
||||
"""PIS/COFINS/RTC blockers are ordinary blockers; when a later FULL pass no longer reports them (data fixed / RTC switched off) they close automatically."""
|
||||
from central.app.models import BlockedProduct
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
operational = {**OPERATIONAL, "COMPLETE": 519, "BLOCKED_MISSING_EVIDENCE": 0, "BLOCKED_CONFLICT": 0, "BLOCKED_FISCAL_DATA": 2, "SAFE_BLOCKED": 2, "TOTAL_PRODUCTS": 521}
|
||||
first = {"operational": operational, "blocked_products": [_fiscal_blocker("10", "PIS", "PIS_CST_INVALID", "a"), _fiscal_blocker("11", "RTC", "CCLASSTRIB_MISSING", "b")], "resolved_products": [],
|
||||
"fiscal_context": {"rtc_enabled": True, "evaluated_all": True}}
|
||||
finish(client, engine, first)
|
||||
with Session(engine) as s:
|
||||
rows = s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_(None))).all()
|
||||
assert sorted((r.product_code, r.field) for r in rows) == [("10", "PIS"), ("11", "RTC")]
|
||||
snap = s.scalars(select(AgentSnapshot)).one()
|
||||
assert (snap.fiscally_complete, snap.safe_blocked, snap.total_products) == (519, 2, 521)
|
||||
age_last_sync(engine, 4000)
|
||||
hb(client)
|
||||
second = {"operational": {**OPERATIONAL, "COMPLETE": 520, "BLOCKED_MISSING_EVIDENCE": 0, "BLOCKED_CONFLICT": 0, "BLOCKED_FISCAL_DATA": 1, "SAFE_BLOCKED": 1},
|
||||
"blocked_products": [_fiscal_blocker("10", "PIS", "PIS_CST_INVALID", "a")], "resolved_products": [], "fiscal_context": {"rtc_enabled": False, "evaluated_all": True}}
|
||||
finish(client, engine, second)
|
||||
with Session(engine) as s:
|
||||
open_rows = s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_(None))).all()
|
||||
assert [(r.product_code, r.field) for r in open_rows] == [("10", "PIS")] # RTC blocker closed because RTC is off now
|
||||
closed = s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_not(None))).all()
|
||||
assert [(r.product_code, r.resolution_source) for r in closed] == [("11", "AUTO_REEVALUATION")]
|
||||
|
||||
|
||||
def test_partial_reprocess_never_closes_fiscal_blockers_of_other_products():
|
||||
from central.app.models import BlockedProduct
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [_fiscal_blocker("10", "PIS", "PIS_CST_INVALID", "a")], "resolved_products": [], "fiscal_context": {"evaluated_all": True}})
|
||||
age_last_sync(engine, 4000)
|
||||
hb(client)
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": {"evaluated_all": False}})
|
||||
with Session(engine) as s:
|
||||
assert len(s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_(None))).all()) == 1
|
||||
|
||||
|
||||
def test_rtc_on_then_off_and_pis_fix_leave_no_stale_open_blockers_and_never_duplicate():
|
||||
from central.app.models import BlockedProduct
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
rtc_missing = _fiscal_blocker("20", "RTC", "CCLASSTRIB_MISSING", "r1")
|
||||
pis_invalid = _fiscal_blocker("21", "PIS", "PIS_CST_INVALID", "p1")
|
||||
op = {**OPERATIONAL, "BLOCKED_MISSING_EVIDENCE": 0, "BLOCKED_CONFLICT": 0}
|
||||
finish(client, engine, {"operational": {**op, "COMPLETE": 519, "BLOCKED_FISCAL_DATA": 2, "SAFE_BLOCKED": 2}, "blocked_products": [rtc_missing, pis_invalid, dict(rtc_missing)], "resolved_products": [],
|
||||
"fiscal_context": {"rtc_enabled": True, "evaluated_all": True}})
|
||||
with Session(engine) as s:
|
||||
assert len(s.scalars(select(BlockedProduct)).all()) == 2 # the repeated blocker is one row
|
||||
age_last_sync(engine, 4000)
|
||||
hb(client)
|
||||
# RTC switched off in the ERP AND the PIS value was normalised/fixed: the next full pass reports nothing
|
||||
finish(client, engine, {"operational": {**op, "COMPLETE": 521}, "blocked_products": [], "resolved_products": [], "fiscal_context": {"rtc_enabled": False, "evaluated_all": True}})
|
||||
with Session(engine) as s:
|
||||
rows = s.scalars(select(BlockedProduct)).all()
|
||||
assert [r.resolved_at is not None for r in rows] == [True, True] and {r.resolution_source for r in rows} == {"AUTO_REEVALUATION"}
|
||||
assert s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_(None))).all() == []
|
||||
age_last_sync(engine, 4000)
|
||||
hb(client)
|
||||
# RTC comes back on: the SAME blocker (same fingerprint) reopens instead of being duplicated
|
||||
finish(client, engine, {"operational": {**op, "COMPLETE": 520, "BLOCKED_FISCAL_DATA": 1, "SAFE_BLOCKED": 1}, "blocked_products": [rtc_missing], "resolved_products": [], "fiscal_context": {"rtc_enabled": True, "evaluated_all": True}})
|
||||
with Session(engine) as s:
|
||||
rows = s.scalars(select(BlockedProduct)).all()
|
||||
assert len(rows) == 2 and [(r.product_code, r.resolved_at is None) for r in rows] == [("20", True), ("21", False)]
|
||||
|
||||
|
||||
def test_job_carries_the_central_rtc_authorization_default_off_and_follows_the_installation():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
assert {k: v for k, v in job["authorizations"].items() if k != "features"} == {"rtc_write_mode": "DISABLED", "rtc_approved_codes": [], "rtc_rollbacks": [], "cfop_ippt_write_mode": "DISABLED"} # never on by default
|
||||
client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {}}, headers=H)
|
||||
with Session(engine) as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
inst.rtc_write_mode = "ENABLED"
|
||||
s.commit()
|
||||
age_last_sync(engine, 4000)
|
||||
hb(client)
|
||||
assert client.get("/v1/jobs/next", headers=H).json()["authorizations"]["rtc_write_mode"] == "ENABLED"
|
||||
|
||||
|
||||
def test_rtc_write_audits_are_persisted_with_server_side_identity_and_never_duplicated_per_job():
|
||||
from central.app.models import AgentEvent, RtcWriteAudit
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
applied = {"outcome": "APPLIED", "product_code": "10", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "evidence": {"reason": "ANNEX_I_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS"},
|
||||
"official_source_version": {"cclasstrib_sha256": "ab" * 32}, "local_catalog_hash": "cd" * 32, "timestamp": "2026-09-24T20:00:00+00:00", "job_id": 999, "tenant_id": 12345, "agent_id": 777}
|
||||
aborted = {"outcome": "ABORTED", "product_code": "11", "field": "CCLASSTRIB", "reason": "RTC_NO_LONGER_ENABLED", "rule_id": "RTC-R4", "timestamp": "2026-09-24T20:00:01+00:00"}
|
||||
other_field = {"outcome": "APPLIED", "product_code": "12", "field": "IPI", "new_value": "1.0"} # anything but CCLASSTRIB is refused
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"mode": "AUTO_APPLY", "applied": [applied, other_field], "aborted": [aborted], "aborted_reasons": {"RTC_NO_LONGER_ENABLED": 1}}})
|
||||
with Session(engine) as s:
|
||||
rows = s.scalars(select(RtcWriteAudit).order_by(RtcWriteAudit.id)).all()
|
||||
assert [(r.product_code, r.outcome, r.old_value, r.new_value, r.rule_id) for r in rows] == [("10", "APPLIED", None, "200003", "RTC-R3"), ("11", "ABORTED", None, None, "RTC-R4")]
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
assert all(r.installation_id == inst.id and r.agent_id == inst.agent_id and r.tenant_id == inst.tenant_id for r in rows) # identity comes from the authenticated context, not the payload
|
||||
assert rows[0].local_catalog_hash == "cd" * 32 and rows[0].official_source_version["cclasstrib_sha256"] == "ab" * 32 and rows[0].job_id != 999
|
||||
events = [e for e in s.scalars(select(AgentEvent)).all() if e.event_type == "RTC_AUTO_APPLY"]
|
||||
assert len(events) == 1 and events[0].structured_context["applied"] == 1 and events[0].severity == "WARNING"
|
||||
|
||||
|
||||
def test_report_only_jobs_leave_no_audit_rows():
|
||||
from central.app.models import RtcWriteAudit
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"mode": "REPORT_ONLY", "applied": [], "aborted": [], "ready_total": 280}})
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(RtcWriteAudit)).all() == []
|
||||
|
||||
|
||||
def _set_mode(engine, mode):
|
||||
with Session(engine) as s:
|
||||
s.scalars(select(Installation)).one().rtc_write_mode = mode
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_preview_is_stored_only_when_the_central_mode_wants_it_and_approvals_travel_in_the_next_job():
|
||||
from central.app.models import RtcCandidate
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
preview = [{"product_code": f"P{i}", "description": f"ITEM {i}", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "RTC-R3:ANNEX"} for i in range(3)]
|
||||
ctx = {"rtc_enabled": True, "evaluated_all": True}
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx, "rtc_auto_apply": {"mode": "REPORT_ONLY", "preview": preview, "preview_truncated": False}})
|
||||
with Session(engine) as s:
|
||||
assert s.scalars(select(RtcCandidate)).all() == [] # DISABLED: nothing is stored
|
||||
_set_mode(engine, "APPROVAL_REQUIRED")
|
||||
age_last_sync(engine, 4000); hb(client)
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx, "rtc_auto_apply": {"mode": "APPROVAL_REQUIRED", "preview": preview, "preview_truncated": False}})
|
||||
with Session(engine) as s:
|
||||
rows = s.scalars(select(RtcCandidate).order_by(RtcCandidate.product_code)).all()
|
||||
assert [(r.product_code, r.status, r.proposed) for r in rows] == [("P0", "PENDING", "200003"), ("P1", "PENDING", "200003"), ("P2", "PENDING", "200003")]
|
||||
rows[1].status = "APPROVED"
|
||||
s.commit()
|
||||
age_last_sync(engine, 4000); hb(client)
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
assert job["authorizations"]["rtc_approved_codes"] == ["P1"] # only what a human approved
|
||||
client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx,
|
||||
"rtc_auto_apply": {"mode": "APPROVAL_REQUIRED", "preview": [p for p in preview if p["product_code"] != "P2"], "preview_truncated": False,
|
||||
"applied": [{"outcome": "APPLIED", "product_code": "P1", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY", "rule_version": "rtc-rules-v2",
|
||||
"company_crt": "3", "authorization_origin": "HUB_MODE:APPROVAL_REQUIRED", "description_snapshot": "ITEM 1", "rtc_status": "ENABLED", "local_catalog_state": "MATCH_SAFE"}]}}}, headers=H)
|
||||
with Session(engine) as s:
|
||||
status = {r.product_code: r.status for r in s.scalars(select(RtcCandidate)).all()}
|
||||
assert status == {"P0": "PENDING", "P1": "APPLIED", "P2": "STALE"} # applied is recorded; a product no longer eligible on a full evaluation becomes STALE
|
||||
from central.app.models import RtcWriteAudit
|
||||
audit = s.scalars(select(RtcWriteAudit)).one()
|
||||
assert (audit.rule_version, audit.company_crt, audit.authorization_origin, audit.description_snapshot, audit.action, audit.local_catalog_state) == ("rtc-rules-v2", "3", "HUB_MODE:APPROVAL_REQUIRED", "ITEM 1", "APPLY", "MATCH_SAFE")
|
||||
assert audit.installation_uuid
|
||||
|
||||
|
||||
def test_rollback_request_travels_in_the_job_and_its_result_closes_the_original_audit():
|
||||
from central.app.models import RtcWriteAudit
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
applied = {"outcome": "APPLIED", "product_code": "P1", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY"}
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"applied": [applied]}})
|
||||
with Session(engine) as s:
|
||||
audit = s.scalars(select(RtcWriteAudit)).one(); audit.rollback_state = "REQUESTED"; audit_id = audit.id
|
||||
s.commit()
|
||||
age_last_sync(engine, 4000); hb(client)
|
||||
job = client.get("/v1/jobs/next", headers=H).json()
|
||||
assert job["authorizations"]["rtc_rollbacks"] == [{"audit_id": audit_id, "product_code": "P1", "written_value": "200003", "rule_id": "RTC-R3"}]
|
||||
done = {"outcome": "ROLLED_BACK", "product_code": "P1", "field": "CCLASSTRIB", "old_value": "200003", "new_value": None, "rule_id": "RTC-R3", "action": "ROLLBACK", "rollback_of": audit_id}
|
||||
client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"rollbacks": [done]}}}, headers=H)
|
||||
with Session(engine) as s:
|
||||
rows = s.scalars(select(RtcWriteAudit).order_by(RtcWriteAudit.id)).all()
|
||||
assert [(r.action, r.outcome, r.rollback_state, r.rollback_of) for r in rows] == [("APPLY", "APPLIED", "DONE", None), ("ROLLBACK", "ROLLED_BACK", None, audit_id)]
|
||||
Reference in New Issue
Block a user