Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for Lernotafiscal."""
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Testes do app: fallback de data, autenticação e CRUD de documentos."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DateFallbackTests(unittest.TestCase):
|
||||
def test_iso_date_is_kept(self):
|
||||
from app.dates import resolve_competencia
|
||||
|
||||
self.assertEqual(resolve_competencia("2026-06-09"), (6, 2026))
|
||||
|
||||
def test_br_date_is_normalized(self):
|
||||
from app.dates import resolve_competencia
|
||||
|
||||
self.assertEqual(resolve_competencia("09/06/2026"), (6, 2026))
|
||||
|
||||
def test_illegible_date_falls_back_to_current_month(self):
|
||||
from app.dates import resolve_competencia
|
||||
|
||||
ref = date(2026, 7, 24)
|
||||
self.assertEqual(resolve_competencia("ilegível", reference=ref), (7, 2026))
|
||||
self.assertEqual(resolve_competencia(None, reference=ref), (7, 2026))
|
||||
self.assertEqual(resolve_competencia("", reference=ref), (7, 2026))
|
||||
|
||||
def test_invalid_calendar_date_falls_back(self):
|
||||
from app.dates import resolve_competencia
|
||||
|
||||
ref = date(2026, 7, 24)
|
||||
self.assertEqual(resolve_competencia("2026-13-40", reference=ref), (7, 2026))
|
||||
|
||||
|
||||
class PasswordTests(unittest.TestCase):
|
||||
def test_hash_and_verify(self):
|
||||
from app import auth
|
||||
|
||||
h = auth.hash_password("segredo-forte")
|
||||
self.assertTrue(auth.verify_password("segredo-forte", h))
|
||||
self.assertFalse(auth.verify_password("errada", h))
|
||||
|
||||
|
||||
class CrudTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
os.environ["DB_PATH"] = str(Path(self._tmp.name) / "crud.sqlite3")
|
||||
# zera o cache de settings para pegar o DB_PATH novo
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("DB_PATH", None)
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_create_update_delete_list(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
doc_id = db.create_fiscal(
|
||||
conn, mes=7, ano=2026, supplier_name="Padaria X", total_paid=10.0
|
||||
)
|
||||
self.assertEqual(db.overall_totals(conn)["count"], 1)
|
||||
|
||||
db.update_fiscal(
|
||||
conn, doc_id, mes=7, ano=2026, supplier_name="Padaria Y", total_paid=12.5
|
||||
)
|
||||
row = db.get_fiscal(conn, doc_id)
|
||||
self.assertEqual(row["supplier_name"], "Padaria Y")
|
||||
self.assertEqual(row["total_paid"], 12.5)
|
||||
|
||||
rows = db.list_fiscal(conn, supplier="Padaria")
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
db.delete_fiscal(conn, doc_id)
|
||||
self.assertEqual(db.overall_totals(conn)["count"], 0)
|
||||
|
||||
|
||||
class StagingGateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
os.environ["DB_PATH"] = str(Path(self._tmp.name) / "stage.sqlite3")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("DB_PATH", None)
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _stage(self, conn, batch_id, upload_id, **over):
|
||||
base = dict(
|
||||
upload_id=upload_id, batch_id=batch_id, source_file_name="f.pdf",
|
||||
source_page=None, source_location="f.pdf", raw_text="",
|
||||
mes=7, ano=2026, supplier_name="Loja", total_paid=10.0,
|
||||
confidence="high", field_confidence={}, legible=True,
|
||||
uncertain_fields=[], extractor="local",
|
||||
)
|
||||
base.update(over)
|
||||
from app import database as db
|
||||
|
||||
return db.insert_detected(conn, **base)
|
||||
|
||||
def test_incomplete_row_counts_as_pending_and_is_not_imported(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up) # completo
|
||||
# legível mas SEM fornecedor -> incompleto, deve contar como pendente
|
||||
self._stage(conn, batch, up, supplier_name=None, legible=True)
|
||||
|
||||
summary = db.batch_summary(conn, batch)
|
||||
self.assertEqual(summary["count"], 2)
|
||||
self.assertEqual(summary["incompletos"], 1)
|
||||
self.assertEqual(summary["pendentes"], 1)
|
||||
|
||||
def test_missing_competencia_counts_as_pending_and_blocks_confirmation(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up) # completo
|
||||
# legível, fornecedor e valor ok, mas SEM competência -> pendente
|
||||
self._stage(conn, batch, up, mes=None, ano=None)
|
||||
|
||||
summary = db.batch_summary(conn, batch)
|
||||
self.assertEqual(summary["pendentes"], 1)
|
||||
|
||||
inserted = db.confirm_batch(conn, batch)
|
||||
self.assertEqual(inserted, 1)
|
||||
|
||||
def test_confirm_only_promotes_complete_rows(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up, supplier_name="Loja A", total_paid=10.0)
|
||||
self._stage(conn, batch, up, supplier_name="Loja B", total_paid=20.0)
|
||||
inserted = db.confirm_batch(conn, batch)
|
||||
self.assertEqual(inserted, 2)
|
||||
self.assertEqual(db.overall_totals(conn)["total"], 30.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Testes de categorização: palavra-chave, categorize_supplier (cache/limite/IA) e CRUD de categoria."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class _DbTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
os.environ["DB_PATH"] = str(Path(self._tmp.name) / "cat.sqlite3")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app import categorization
|
||||
|
||||
categorization.reset_cache()
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("DB_PATH", None)
|
||||
os.environ.pop("OPENAI_API_KEY", None)
|
||||
os.environ.pop("CATEGORIZATION_MAX_AI_CALLS_PER_BATCH", None)
|
||||
os.environ.pop("ADMIN_USERNAME", None)
|
||||
os.environ.pop("ADMIN_PASSWORD", None)
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app import categorization
|
||||
|
||||
categorization.reset_cache()
|
||||
self._tmp.cleanup()
|
||||
|
||||
|
||||
class FindCategoriaByKeywordTests(_DbTestCase):
|
||||
def test_match_found(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
row = db.find_categoria_by_keyword(conn, "Supermercado Bom Preço")
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["categoria"], "Alimentação")
|
||||
|
||||
def test_no_match(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
row = db.find_categoria_by_keyword(conn, "Posto de Gasolina XYZ")
|
||||
self.assertIsNone(row)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, "Transporte", "posto")
|
||||
row = db.find_categoria_by_keyword(conn, "POSTO SHELL CENTRO")
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["categoria"], "Transporte")
|
||||
|
||||
def test_multiple_candidates_first_by_id_wins(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
first_id = db.create_categoria(conn, "Geral", "MERCADO")
|
||||
db.create_categoria(conn, "Específico", "MERCADO CENTRAL")
|
||||
row = db.find_categoria_by_keyword(conn, "Mercado Central Ltda")
|
||||
self.assertEqual(row["id"], first_id)
|
||||
self.assertEqual(row["categoria"], "Geral")
|
||||
|
||||
def test_reserved_row_never_returned_even_with_empty_keyword(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
# id=1 "Não Encontrado" já existe com palavra_chave='' (substring de tudo).
|
||||
row = db.find_categoria_by_keyword(conn, "Qualquer Fornecedor")
|
||||
self.assertIsNone(row)
|
||||
self.assertEqual(db.get_categoria(conn, 1)["categoria"], "Não Encontrado")
|
||||
|
||||
|
||||
class CategorizeSupplierTests(_DbTestCase):
|
||||
def test_cache_hit_skips_ai(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.categorization._call_ai", return_value="Alimentação") as mocked:
|
||||
first = categorization.categorize_supplier("Padaria X", conn, batch_id)
|
||||
second = categorization.categorize_supplier("padaria x", conn, batch_id)
|
||||
self.assertEqual(first, cat_id)
|
||||
self.assertEqual(second, cat_id)
|
||||
mocked.assert_called_once()
|
||||
|
||||
def test_batch_limit_reached_skips_ai_and_uses_keyword_fallback(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
os.environ["CATEGORIZATION_MAX_AI_CALLS_PER_BATCH"] = "1"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Alimentação", "PADARIA")
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.categorization._call_ai", return_value=None) as mocked:
|
||||
categorization.categorize_supplier("Fornecedor Um", conn, batch_id)
|
||||
self.assertEqual(mocked.call_count, 1)
|
||||
result = categorization.categorize_supplier("Padaria Dois", conn, batch_id)
|
||||
# 2º fornecedor: limite já atingido -> IA não é chamada, cai no fallback de palavra-chave
|
||||
self.assertEqual(mocked.call_count, 1)
|
||||
self.assertEqual(result, cat_id)
|
||||
|
||||
def test_ai_returns_valid_category(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Saúde", "FARMACIA")
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.categorization._call_ai", return_value="Saúde"):
|
||||
result = categorization.categorize_supplier("Drogaria Central", conn, batch_id)
|
||||
self.assertEqual(result, cat_id)
|
||||
|
||||
def test_ai_fails_or_invalid_falls_back_to_keyword(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Transporte", "POSTO")
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.categorization._call_ai", return_value=None):
|
||||
result = categorization.categorize_supplier("Posto Ipiranga", conn, batch_id)
|
||||
self.assertEqual(result, cat_id)
|
||||
|
||||
def test_no_method_finds_category_returns_reserved(self):
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
with db.session() as conn:
|
||||
batch_id = db.create_batch(conn)
|
||||
result = categorization.categorize_supplier("Fornecedor Desconhecido", conn, batch_id)
|
||||
self.assertEqual(result, db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
def test_unexpected_internal_failure_never_propagates_and_falls_back_to_reserved(self):
|
||||
"""3.2: qualquer exceção não tratada dentro do módulo -> categoria_id=1,
|
||||
nunca propaga para o chamador (que criaria fiscal_documents)."""
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
with db.session() as conn:
|
||||
batch_id = db.create_batch(conn)
|
||||
with patch("app.database.list_categorias", side_effect=RuntimeError("db explodiu")):
|
||||
result = categorization.categorize_supplier("Fornecedor Y", conn, batch_id)
|
||||
self.assertEqual(result, db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
def test_repeated_suppliers_call_ai_once_each_and_respect_batch_limit(self):
|
||||
"""Equivalente automatizado da validação manual 6.5: lote com fornecedores
|
||||
repetidos chama a IA no máximo uma vez por fornecedor distinto, e respeita
|
||||
o limite por lote (observável via logs)."""
|
||||
from app import database as db
|
||||
from app import categorization
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
os.environ["CATEGORIZATION_MAX_AI_CALLS_PER_BATCH"] = "2"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
batch_id = db.create_batch(conn)
|
||||
suppliers = ["Fornecedor A", "Fornecedor A", "Fornecedor B", "Mercado C", "Fornecedor A"]
|
||||
with patch("app.categorization._call_ai", return_value=None) as mocked, \
|
||||
self.assertLogs("lernotafiscal.categorization", level="INFO") as logs:
|
||||
for name in suppliers:
|
||||
categorization.categorize_supplier(name, conn, batch_id)
|
||||
# 2 fornecedores distintos com IA tentada (A, B) antes do limite=2 ser atingido
|
||||
self.assertEqual(mocked.call_count, 2)
|
||||
self.assertTrue(any("limite" in msg for msg in logs.output))
|
||||
|
||||
|
||||
class CategoriaCrudTests(_DbTestCase):
|
||||
def test_create_get_list_update(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Lazer", "CINEMA")
|
||||
row = db.get_categoria(conn, cat_id)
|
||||
self.assertEqual(row["categoria"], "Lazer")
|
||||
|
||||
db.update_categoria(conn, cat_id, "Entretenimento", "CINEMA")
|
||||
row = db.get_categoria(conn, cat_id)
|
||||
self.assertEqual(row["categoria"], "Entretenimento")
|
||||
|
||||
rows = db.list_categorias(conn)
|
||||
self.assertTrue(any(r["id"] == cat_id for r in rows))
|
||||
# linha reservada sempre presente
|
||||
self.assertTrue(any(r["id"] == 1 for r in rows))
|
||||
|
||||
def test_delete_reassigns_referencing_documents_to_reserved(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Lazer", "CINEMA")
|
||||
doc_id = db.create_fiscal(conn, mes=7, ano=2026, supplier_name="Cinema X", total_paid=30.0)
|
||||
conn.execute("UPDATE fiscal_documents SET categoria_id = ? WHERE id = ?", (cat_id, doc_id))
|
||||
conn.commit()
|
||||
|
||||
deleted = db.delete_categoria(conn, cat_id)
|
||||
self.assertTrue(deleted)
|
||||
self.assertIsNone(db.get_categoria(conn, cat_id))
|
||||
self.assertEqual(db.get_fiscal(conn, doc_id)["categoria_id"], db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
def test_delete_reserved_row_rejected(self):
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
deleted = db.delete_categoria(conn, db.RESERVED_CATEGORIA_ID)
|
||||
self.assertFalse(deleted)
|
||||
self.assertIsNotNone(db.get_categoria(conn, db.RESERVED_CATEGORIA_ID))
|
||||
|
||||
def test_sem_categoria_group_and_filter_cover_legacy_null_rows(self):
|
||||
"""Cobre o grupo/filtro 'Sem categoria' (categoria_id IS NULL, documento
|
||||
legado nunca reprocessado), distinto da categoria reservada 'Não Encontrado'
|
||||
(categoria_id = 1, usada pelo fluxo de categorização ativo)."""
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
# Legado: create_fiscal nunca seta categoria_id -> permanece NULL.
|
||||
legacy_id = db.create_fiscal(
|
||||
conn, mes=7, ano=2026, supplier_name="Doc Legado", total_paid=40.0
|
||||
)
|
||||
self.assertIsNone(db.get_fiscal(conn, legacy_id)["categoria_id"])
|
||||
|
||||
# Documento processado pelo fluxo, sem match -> categoria_id = 1 (reservado).
|
||||
found_id = db.create_fiscal(
|
||||
conn, mes=7, ano=2026, supplier_name="Doc Não Encontrado", total_paid=15.0
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE fiscal_documents SET categoria_id = ? WHERE id = ?",
|
||||
(db.RESERVED_CATEGORIA_ID, found_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
totals = {r["categoria"]: r for r in db.category_totals(conn)}
|
||||
self.assertIn("Sem categoria", totals)
|
||||
self.assertEqual(totals["Sem categoria"]["total"], 40.0)
|
||||
self.assertIsNone(totals["Sem categoria"]["categoria_id"])
|
||||
self.assertIn("Não Encontrado", totals)
|
||||
self.assertEqual(totals["Não Encontrado"]["total"], 15.0)
|
||||
|
||||
none_filtered = db.list_fiscal(conn, category="none")
|
||||
self.assertEqual(len(none_filtered), 1)
|
||||
self.assertEqual(none_filtered[0]["id"], legacy_id)
|
||||
|
||||
reserved_filtered = db.list_fiscal(conn, category=str(db.RESERVED_CATEGORIA_ID))
|
||||
self.assertEqual(len(reserved_filtered), 1)
|
||||
self.assertEqual(reserved_filtered[0]["id"], found_id)
|
||||
|
||||
self.assertEqual(db.overall_totals(conn, category="none")["count"], 1)
|
||||
self.assertEqual(db.monthly_totals(conn, category="none")[0]["total"], 40.0)
|
||||
self.assertEqual(db.supplier_totals(conn, category="none")[0]["supplier_name"], "Doc Legado")
|
||||
|
||||
|
||||
class IngestionCategorizationIntegrationTests(_DbTestCase):
|
||||
"""6.3: fluxo de ingestão ponta a ponta (confirm_batch) resulta em categoria_id
|
||||
correto para os cenários de IA, palavra-chave e 'Não Encontrado'."""
|
||||
|
||||
def _stage(self, conn, batch_id, upload_id, supplier_name, total_paid):
|
||||
from app import database as db
|
||||
|
||||
return db.insert_detected(
|
||||
conn, upload_id=upload_id, batch_id=batch_id, source_file_name="f.pdf",
|
||||
source_page=None, source_location="f.pdf", raw_text="",
|
||||
mes=7, ano=2026, supplier_name=supplier_name, total_paid=total_paid,
|
||||
confidence="high", field_confidence={}, legible=True, uncertain_fields=[], extractor="local",
|
||||
)
|
||||
|
||||
def test_confirm_batch_assigns_categoria_via_ai_keyword_and_reserved(self):
|
||||
from app import database as db
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "test-key"
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
with db.session() as conn:
|
||||
saude_id = db.create_categoria(conn, "Saúde", "FARMACIA")
|
||||
alimentacao_id = db.create_categoria(conn, "Alimentação", "MERCADO")
|
||||
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up, "Drogaria Central", 10.0) # IA acerta -> Saúde
|
||||
self._stage(conn, batch, up, "Mercado da Esquina", 20.0) # IA falha -> palavra-chave
|
||||
self._stage(conn, batch, up, "Loja Sem Categoria Nenhuma", 30.0) # nada casa -> reservado
|
||||
|
||||
def fake_ai(supplier_name, known_categories):
|
||||
return "Saúde" if supplier_name == "Drogaria Central" else None
|
||||
|
||||
with patch("app.categorization._call_ai", side_effect=fake_ai):
|
||||
inserted = db.confirm_batch(conn, batch)
|
||||
|
||||
self.assertEqual(inserted, 3)
|
||||
rows = {r["supplier_name"]: r for r in db.list_fiscal(conn)}
|
||||
self.assertEqual(rows["Drogaria Central"]["categoria_id"], saude_id)
|
||||
self.assertEqual(rows["Mercado da Esquina"]["categoria_id"], alimentacao_id)
|
||||
self.assertEqual(rows["Loja Sem Categoria Nenhuma"]["categoria_id"], db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
def test_confirm_batch_saves_document_with_reserved_category_when_nothing_matches(self):
|
||||
"""3.2: nenhum erro bloqueia a criação do fiscal_documents; sem IA/palavra-chave
|
||||
casando, o documento é salvo com categoria_id=1 ("Não Encontrado")."""
|
||||
from app import database as db
|
||||
|
||||
with db.session() as conn:
|
||||
batch = db.create_batch(conn)
|
||||
up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1)
|
||||
self._stage(conn, batch, up, "Fornecedor Totalmente Novo", 10.0)
|
||||
|
||||
inserted = db.confirm_batch(conn, batch)
|
||||
|
||||
self.assertEqual(inserted, 1)
|
||||
row = db.list_fiscal(conn)[0]
|
||||
self.assertEqual(row["categoria_id"], db.RESERVED_CATEGORIA_ID)
|
||||
|
||||
|
||||
class CategoriaFormValidationRouteTests(_DbTestCase):
|
||||
"""Cobre, via requisição HTTP real (rota + CSRF + sessão), o cenário de spec
|
||||
'User submits an invalid categoria form' que não tinha teste automatizado."""
|
||||
|
||||
def _login(self, client):
|
||||
page = client.get("/login")
|
||||
token = re.search(r'name="csrf_token" value="([^"]+)"', page.text).group(1)
|
||||
client.post(
|
||||
"/login",
|
||||
data={"csrf_token": token, "username": "admin", "password": "test-pass-123"},
|
||||
)
|
||||
|
||||
def _csrf(self, page_html: str) -> str:
|
||||
return re.search(r'name="csrf_token" value="([^"]+)"', page_html).group(1)
|
||||
|
||||
def test_invalid_new_categoria_form_does_not_create_row(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import database as db
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
|
||||
os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "test-pass-123"
|
||||
get_settings.cache_clear()
|
||||
|
||||
with TestClient(app) as client:
|
||||
self._login(client)
|
||||
|
||||
with db.session() as conn:
|
||||
before = len(db.list_categorias(conn))
|
||||
|
||||
new_page = client.get("/categorias/new")
|
||||
resp = client.post(
|
||||
"/categorias/new",
|
||||
data={"csrf_token": self._csrf(new_page.text), "categoria": "", "palavra_chave": ""},
|
||||
)
|
||||
self.assertIn("Informe categoria e palavra-chave", resp.text)
|
||||
|
||||
with db.session() as conn:
|
||||
after = len(db.list_categorias(conn))
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_invalid_edit_categoria_form_does_not_change_row(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import database as db
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
|
||||
os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "test-pass-123"
|
||||
get_settings.cache_clear()
|
||||
|
||||
with TestClient(app) as client:
|
||||
self._login(client)
|
||||
|
||||
with db.session() as conn:
|
||||
cat_id = db.create_categoria(conn, "Lazer", "CINEMA")
|
||||
|
||||
edit_page = client.get(f"/categorias/{cat_id}/edit")
|
||||
resp = client.post(
|
||||
f"/categorias/{cat_id}/edit",
|
||||
data={"csrf_token": self._csrf(edit_page.text), "categoria": "", "palavra_chave": ""},
|
||||
)
|
||||
self.assertIn("Informe categoria e palavra-chave", resp.text)
|
||||
|
||||
with db.session() as conn:
|
||||
row = db.get_categoria(conn, cat_id)
|
||||
self.assertEqual(row["categoria"], "Lazer")
|
||||
self.assertEqual(row["palavra_chave"], "CINEMA")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from lernotafiscal import db
|
||||
from lernotafiscal.extraction import detect_documents, extract_pdf_text_naive, segment_blocks
|
||||
from lernotafiscal.extraction import PageSource
|
||||
|
||||
|
||||
SINGLE_DOC = """
|
||||
MERCADO CENTRAL LTDA
|
||||
CNPJ 12.345.678/0001-90
|
||||
DATA 13/07/2026
|
||||
ITEM ARROZ 10,00
|
||||
VALOR TOTAL R$ 42,50
|
||||
"""
|
||||
|
||||
|
||||
MULTI_DOC = """
|
||||
MERCADO CENTRAL LTDA
|
||||
CNPJ 12.345.678/0001-90
|
||||
DATA 13/07/2026
|
||||
VALOR TOTAL R$ 42,50
|
||||
|
||||
FARMACIA SAUDE
|
||||
CNPJ 98.765.432/0001-10
|
||||
DATA 14/07/2026
|
||||
TOTAL A PAGAR R$ 18,90
|
||||
"""
|
||||
|
||||
|
||||
class ExtractionTests(unittest.TestCase):
|
||||
def test_single_image_text_creates_one_candidate(self) -> None:
|
||||
pages = [PageSource(1, SINGLE_DOC, None, "image")]
|
||||
docs = detect_documents(pages, "cupom.png")
|
||||
|
||||
self.assertEqual(len(docs), 1)
|
||||
self.assertEqual(docs[0].purchase_date, "2026-07-13")
|
||||
self.assertEqual(docs[0].supplier_name, "MERCADO CENTRAL LTDA")
|
||||
self.assertEqual(docs[0].total_paid, 42.50)
|
||||
|
||||
def test_pdf_page_with_multiple_blocks_creates_multiple_candidates(self) -> None:
|
||||
blocks = segment_blocks(MULTI_DOC)
|
||||
self.assertEqual(len(blocks), 2)
|
||||
|
||||
docs = detect_documents([PageSource(1, MULTI_DOC, None, "pdf")], "lote.pdf")
|
||||
self.assertEqual(len(docs), 2)
|
||||
self.assertEqual(docs[0].supplier_name, "MERCADO CENTRAL LTDA")
|
||||
self.assertEqual(docs[1].supplier_name, "FARMACIA SAUDE")
|
||||
|
||||
def test_pdf_with_one_document_per_page_creates_review_item_per_page(self) -> None:
|
||||
pages = [
|
||||
PageSource(1, SINGLE_DOC, None, "pdf"),
|
||||
PageSource(2, SINGLE_DOC.replace("MERCADO CENTRAL LTDA", "POSTO AVENIDA"), None, "pdf"),
|
||||
]
|
||||
docs = detect_documents(pages, "duas-paginas.pdf")
|
||||
|
||||
self.assertEqual(len(docs), 2)
|
||||
self.assertEqual(docs[0].source_location, "pagina 1, bloco 1")
|
||||
self.assertEqual(docs[1].source_location, "pagina 2, bloco 1")
|
||||
self.assertEqual(docs[1].supplier_name, "POSTO AVENIDA")
|
||||
|
||||
def test_naive_pdf_extraction_rejects_binary_streams_without_fake_pages(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pdf_path = Path(tmp) / "scan.pdf"
|
||||
pdf_path.write_bytes(
|
||||
b"%PDF-1.4\n"
|
||||
b"1 0 obj << /Type /Page >> endobj\n"
|
||||
b"2 0 obj << /Length 28 >> stream\n"
|
||||
b"\x01\x02binary\x0cnoise\x0cnot text\xff\n"
|
||||
b"endstream\n%%EOF"
|
||||
)
|
||||
|
||||
pages = extract_pdf_text_naive(pdf_path)
|
||||
|
||||
self.assertEqual(pages, [""])
|
||||
|
||||
def test_low_confidence_candidate_can_be_corrected_and_confirmed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conn = db.connect(Path(tmp) / "test.sqlite3")
|
||||
try:
|
||||
db.init_db(conn)
|
||||
upload_id = db.insert_upload(conn, "scan.png", Path(tmp) / "scan.png", "image/png", 12)
|
||||
candidate = detect_documents([PageSource(1, "", None, "image")], "scan.png")[0]
|
||||
detected_id = db.insert_detected_document(conn, upload_id, candidate)
|
||||
|
||||
db.confirm_document(conn, detected_id, "2026-07-15", "PADARIA BOA", 9.75)
|
||||
docs = db.dashboard_documents(conn)
|
||||
|
||||
self.assertEqual(len(docs), 1)
|
||||
self.assertEqual(docs[0]["supplier_name"], "PADARIA BOA")
|
||||
self.assertEqual(docs[0]["total_paid"], 9.75)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_ignored_documents_do_not_reach_dashboard(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conn = db.connect(Path(tmp) / "test.sqlite3")
|
||||
try:
|
||||
db.init_db(conn)
|
||||
upload_id = db.insert_upload(conn, "cupom.png", Path(tmp) / "cupom.png", "image/png", 12)
|
||||
candidate = detect_documents([PageSource(1, SINGLE_DOC, None, "image")], "cupom.png")[0]
|
||||
detected_id = db.insert_detected_document(conn, upload_id, candidate)
|
||||
|
||||
db.ignore_document(conn, detected_id)
|
||||
docs = db.dashboard_documents(conn)
|
||||
|
||||
self.assertEqual(docs, [])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user