feat: service scaffold, k8s test infra, health
FastAPI + SQLAlchemy async + Alembic scaffold, src-layout (mirrors the
sowai-fiscal lib's own convention), pyproject wired to sowai-fiscal@v0.1.0
via git+https (uv.lock pins the commit). Makefile mirrors auto/Makefile's
k8s-test workflow: syncs into /app/fiscal-svc in the SAME auto-tests pod,
against a dedicated fiscal_svc_test database on the shared Postgres
sidecar, serialized by the SAME lock file the auto uses on purpose so the
two repos' test runs never race in the pod. Dockerfile installs git (the
git+https dependency needs it at uv sync time) and splits the dependency
layer from the project's own editable install for build caching.
GET /v1/health -> {"status": "ok"}, verified green via `make k8s-test`.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.venv
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.env
|
||||
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_dev
|
||||
TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_test
|
||||
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.env
|
||||
keys/
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Service image (uv-based). DATABASE_URL and every secret come from the k8s
|
||||
# Deployment env/secrets at runtime — never baked in.
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# `git`: pyproject.toml depends on `sowai-fiscal` via `git+https` (Gitea
|
||||
# interno, EMENDA F1 do spec) -- `uv sync --frozen` needs the `git` binary
|
||||
# at build time to fetch/pin it. The base `-slim` image does not ship it
|
||||
# (mesma lição C1 que o Dockerfile do auto documenta: quando ele passou a
|
||||
# depender de `sowai-fiscal`, precisou do mesmo apt-get install).
|
||||
# SEM WeasyPrint (nem outras libs nativas) por ora -- DANFE (PDF) só chega
|
||||
# na F4; nada aqui as usa ainda.
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y -qq --no-install-recommends git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Dependency layer (cached unless pyproject/uv.lock change).
|
||||
# `--no-install-project`: at this point only pyproject.toml/uv.lock have
|
||||
# been COPYed -- `src/fiscal_svc` (the project's own package, installed
|
||||
# editable via hatchling per pyproject.toml's `[build-system]`) does not
|
||||
# exist in this layer yet, so installing the PROJECT itself here would fail
|
||||
# (nothing to build). This layer installs every THIRD-PARTY dependency only,
|
||||
# which is what makes it cacheable across source-only changes.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# Application code, then install the project itself (fast: every dependency
|
||||
# is already resolved/installed by the layer above).
|
||||
COPY . .
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
EXPOSE 8140
|
||||
|
||||
CMD ["uv", "run", "uvicorn", "fiscal_svc.main:app", "--host", "0.0.0.0", "--port", "8140"]
|
||||
@@ -0,0 +1,96 @@
|
||||
# SowAI Fiscal Service — dev workflow (tests run IN-CLUSTER on the sow-dev
|
||||
# k8s cluster, in the SAME `auto-tests` pod/Postgres sidecar the `auto`
|
||||
# backend's own suite uses — see `auto/Makefile`'s header comment for that
|
||||
# pod's shape). This repo syncs into its OWN tree (`/app/fiscal-svc`, never
|
||||
# touching `/app/backend`) and runs against its OWN database
|
||||
# (`fiscal_svc_test`, created on first use below, since the sidecar's
|
||||
# `POSTGRES_DB` only pre-creates `auto_test`) — but is serialized by the
|
||||
# SAME lock file (`/tmp/auto-k8s-test.lock`) as the auto's own `make
|
||||
# k8s-test`, on purpose (plan 2026-07-17-fiscal-svc-f2-servico.md, Task 1):
|
||||
# one pod, one Postgres sidecar, one shared lock across both repos' test
|
||||
# runs, for free — a `make k8s-test` here and one in `auto/` from the same
|
||||
# machine can never race each other in the pod.
|
||||
|
||||
NS := autopecas-dev
|
||||
POD = $(shell kubectl -n $(NS) get pod -l app=auto-tests -o jsonpath='{.items[0].metadata.name}')
|
||||
FISCAL_SVC_TEST_DATABASE_URL := postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_test
|
||||
|
||||
.PHONY: k8s-sync k8s-deps k8s-test k8s-test-file k8s-shell k8s-status
|
||||
|
||||
# Stream this repo into /app/fiscal-svc in the runner (excludes .venv/.git/
|
||||
# caches). Also ensures `git` (this service's pyproject depends on
|
||||
# `sowai-fiscal` via git+https, EMENDA F1 -- `uv sync` needs the binary at
|
||||
# resolve time) and `psql`/`createdb` (the migration tests shell out to
|
||||
# them) are present in the runner container, and that the `fiscal_svc_test`
|
||||
# database itself exists on the Postgres sidecar. All three are lost
|
||||
# whenever the runner container restarts (none ship in the base image /
|
||||
# get created for us), so all three are (idempotently, fast when already
|
||||
# present/existing) redone on every sync -- same rationale as the auto's own
|
||||
# k8s-sync ensuring `psql`/WeasyPrint libs.
|
||||
#
|
||||
# The tar OVERLAYS (never removes) -- same reasoning as the auto's own
|
||||
# k8s-sync: the pod is SHARED across worktrees/branches (of EITHER repo), so
|
||||
# `src/`/`tests/`/`alembic/` are pruned before extracting, to avoid a ghost
|
||||
# file from a previous sync poisoning this run.
|
||||
k8s-sync:
|
||||
@echo "→ ensure git in $(POD)"
|
||||
@kubectl -n $(NS) exec $(POD) -c runner -- bash -c 'command -v git >/dev/null 2>&1 || (apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq git >/dev/null 2>&1)' 2>/dev/null
|
||||
@echo "→ ensure psql/createdb in $(POD)"
|
||||
@kubectl -n $(NS) exec $(POD) -c runner -- bash -c 'command -v psql >/dev/null 2>&1 || (apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq postgresql-client >/dev/null 2>&1)' 2>/dev/null
|
||||
@echo "→ ensure database fiscal_svc_test in $(POD)"
|
||||
@kubectl -n $(NS) exec $(POD) -c runner -- bash -c 'createdb -h localhost -U postgres fiscal_svc_test 2>/dev/null || true'
|
||||
@echo "→ prune stale src/tests/alembic in $(POD) (o tar sobrepõe, não remove)"
|
||||
@kubectl -n $(NS) exec $(POD) -c runner -- bash -c 'mkdir -p /app/fiscal-svc && rm -rf /app/fiscal-svc/src /app/fiscal-svc/tests /app/fiscal-svc/alembic' 2>/dev/null
|
||||
@echo "→ sync repo into $(POD)"
|
||||
@tar czf - --exclude='.venv' --exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='.pytest_cache' -C . . 2>/dev/null \
|
||||
| kubectl -n $(NS) exec -i $(POD) -c runner -- tar xzf - -C /app/fiscal-svc 2>/dev/null
|
||||
@echo "✅ synced"
|
||||
|
||||
# Re-resolve deps in the pod (run after changing pyproject/uv.lock).
|
||||
k8s-deps:
|
||||
@kubectl -n $(NS) exec $(POD) -c runner -- bash -c 'cd /app/fiscal-svc && uv sync' 2>&1 | grep -v xattr
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SERIALIZAÇÃO DO POD (lock local): MESMO arquivo de lock do `auto/Makefile`,
|
||||
# de propósito (ver o comentário do topo). O `pkill -f pytest` abaixo é
|
||||
# seguro precisamente PORQUE o lock é compartilhado -- por construção, nenhum
|
||||
# pytest legítimo (deste repo OU do auto) pode estar rodando quando esta
|
||||
# seção crítica começa, já que ambos os `make k8s-test` respeitam o mesmo
|
||||
# arquivo antes de disparar pytest.
|
||||
# ---------------------------------------------------------------------------
|
||||
K8S_TEST_LOCK := /tmp/auto-k8s-test.lock
|
||||
|
||||
define K8S_LOCKED_RUN
|
||||
@bash -c 'while ! mkdir "$(K8S_TEST_LOCK)" 2>/dev/null; do \
|
||||
p=$$(cat "$(K8S_TEST_LOCK)/pid" 2>/dev/null); \
|
||||
if [ -n "$$p" ] && ! kill -0 "$$p" 2>/dev/null; then \
|
||||
echo "🔓 lock órfão (pid $$p morto) — assumindo"; rm -rf "$(K8S_TEST_LOCK)"; continue; \
|
||||
fi; \
|
||||
echo "⏳ pod de testes em uso (pid $${p:-?}) — aguardando 20s..."; sleep 20; \
|
||||
done; \
|
||||
echo $$$$ > "$(K8S_TEST_LOCK)/pid"; \
|
||||
trap "rm -rf $(K8S_TEST_LOCK)" EXIT INT TERM; \
|
||||
$(MAKE) k8s-sync && \
|
||||
kubectl -n $(NS) exec $(POD) -c runner -- bash -c "pkill -f [p]ytest 2>/dev/null; true" && \
|
||||
kubectl -n $(NS) exec $(POD) -c runner -- bash -c "cd /app/fiscal-svc && TEST_DATABASE_URL=$(FISCAL_SVC_TEST_DATABASE_URL) uv run pytest -q $(1)" 2>&1 | grep -v xattr'
|
||||
endef
|
||||
|
||||
# Full suite in-cluster (serializada pelo lock). NOTE: `TEST_DATABASE_URL`
|
||||
# is passed explicitly on the pytest invocation above, NOT left to the
|
||||
# container's own env -- the runner container's env already carries a
|
||||
# `TEST_DATABASE_URL` pointing at the auto's `auto_test` database (baked
|
||||
# into the shared pod spec, `k8s/test-runner.yaml` in the auto repo); without
|
||||
# this override this service's suite would silently run against the WRONG
|
||||
# database (or a database it has no schema in).
|
||||
k8s-test:
|
||||
$(call K8S_LOCKED_RUN,)
|
||||
|
||||
# Single file/selector (serializada pelo lock): make k8s-test-file file=tests/test_health.py
|
||||
k8s-test-file:
|
||||
$(call K8S_LOCKED_RUN,$(file))
|
||||
|
||||
k8s-shell:
|
||||
@kubectl -n $(NS) exec -it $(POD) -c runner -- bash
|
||||
|
||||
k8s-status:
|
||||
@kubectl -n $(NS) get pods,svc
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
@@ -0,0 +1,96 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from fiscal_svc.core.config import settings
|
||||
from fiscal_svc.core.db import Base
|
||||
|
||||
# Uncomment as modules gain SQLAlchemy models, so autogenerate can see them
|
||||
# (mirrors auto/backend/alembic/env.py's own convention):
|
||||
# from fiscal_svc.tenancy import models as tenancy_models # noqa: F401
|
||||
# from fiscal_svc.documents import models as documents_models # noqa: F401
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,68 @@
|
||||
# F2 — `sowai-fiscal-svc` com paridade 1b.1 — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** O serviço fiscal existe: API v1 multi-produto que emite (sem transmitir — paridade 1b.1), guarda certificados e séries como DONO, com idempotência, goldens (incl. ASSINADOS), suíte de contrato HTTP e OpenAPI commitado.
|
||||
|
||||
**Architecture:** FastAPI + SQLAlchemy async + Alembic no repo novo (`git.sowai.com.br/jonatan/sowai-fiscal-svc`, já criado, vazio), embutindo a lib `sowai-fiscal@v0.1.0`. A lógica de emissão/certificado é **PORTADA** do auto (código nosso, já revisado 3x — portar com a tabela de adaptações abaixo, não reinventar). Tenancy `(product_id, tenant_ref, branch_ref)` com refs opacas. Testes no MESMO pod k8s do auto (database própria `fiscal_svc_test` no sidecar Postgres — o lock compartilhado serializa com as suítes do auto de graça).
|
||||
|
||||
**Tech Stack:** Python 3.11, uv, FastAPI, SQLAlchemy 2.0 async, Alembic, pydantic v2, `sowai-fiscal @ git+https…@v0.1.0`, erpbrasil.assinatura, cryptography.
|
||||
|
||||
**Spec (governa):** `auto/docs/superpowers/specs/2026-07-17-sowai-fiscal-svc-design.md` (F2 + decisões 1–8 + emendas F1). Este plano é COPIADO para o repo do serviço no Task 1 (docs/plans/) junto do spec.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Convenções do auto valem aqui: zero enum PG (String + enum Python), soft delete, Decimal com bounds espelhando colunas, `max_length`, 409 estruturado (`shared/errors.py` portado), migration real test (INSERT cru), AST guard do `FOR UPDATE populate_existing` (portar o teste), testes de detecção provados nas duas direções.
|
||||
- Tabela de adaptações do porte (vale para TODO código portado do auto):
|
||||
| No auto | No serviço |
|
||||
|---|---|
|
||||
| `organization_id: UUID` (FK organizations) | `product_id: UUID` (FK products) + `tenant_ref: String(64)` + índices por (product_id, tenant_ref) |
|
||||
| `branch_id: UUID` (FK branches) | `branch_ref: String(64)` + `cnpj: String(14)` (o vínculo forte — validado contra certificado/emitente) |
|
||||
| `require_permission(...)` | `require_product(...)` (API key → Product) |
|
||||
| `Sale`/rotas por venda | não existem — a emissão recebe `DadosEmissao` completo |
|
||||
| anti-oracle 404 por org | anti-oracle 404 por (product_id, tenant_ref) |
|
||||
- `ver_proc` é OBRIGATÓRIO no payload de emissão (emenda F1 — nada de default "sowai-auto" da lib).
|
||||
- Idempotência: `Idempotency-Key` header obrigatório no POST /v1/emissoes; repetida → 200 com o documento existente.
|
||||
- O Dockerfile do serviço JÁ NASCE com `git` no apt (lição C1 da F1).
|
||||
- OpenAPI: `docs/openapi-v1.json` COMMITADO + teste que falha se divergir do gerado (`app.openapi()`).
|
||||
- Testes: Makefile próprio espelhando o do auto (lock compartilhado `/tmp/auto-k8s-test.lock` — MESMO arquivo, de propósito; sync para um diretório PRÓPRIO no pod `/app/fiscal-svc`; database `fiscal_svc_test`).
|
||||
- Commits em inglês; sem push do auto (o auto não muda na F2).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: scaffold + test-infra + health
|
||||
|
||||
**Files (repo `/Volumes/MacHD1/sow/sowai-fiscal-svc`):** `pyproject.toml` (deps acima + dev: pytest, pytest-asyncio, httpx), `src/fiscal_svc/{__init__,main,core/config,core/db}.py`, `Dockerfile` (base slim + git + libs mínimas — SEM WeasyPrint por ora; DANFE é F4), `Makefile` (k8s-test/k8s-test-file/k8s-deps espelhados do auto, sync p/ `/app/fiscal-svc`, database `fiscal_svc_test`, MESMO lock), `docs/` (copiar spec + este plano), `tests/conftest.py` (engine/session/override no padrão do auto), `tests/test_health.py`.
|
||||
- [ ] Health: `GET /v1/health` → `{"status":"ok"}`; teste passa NO POD via `make k8s-test`; commit `feat: service scaffold, k8s test infra, health`.
|
||||
|
||||
### Task 2: tenancy — products + API keys
|
||||
|
||||
**Files:** `src/fiscal_svc/tenancy/{models,service,deps}.py`, migration `products` (id, name, api_key_hash — bcrypt via passlib —, webhook_secret nullable p/ F4, timestamps/soft-delete), `scripts/create_product.py` (CLI: gera key aleatória, imprime UMA vez, salva hash), `tests/test_tenancy.py`.
|
||||
- [ ] `require_product` dependency: `X-Api-Key` → Product vivo (hash check) ou 401; key ausente → 401; produto soft-deletado → 401. Migration real test. Commit.
|
||||
|
||||
### Task 3: models + migrations (as tabelas mudam de dono)
|
||||
|
||||
**Files:** `src/fiscal_svc/documents/models.py` — portar `FiscalCertificate`, `FiscalDocument`, e **`FiscalSeries`** (novo dono da numeração: portar `FiscalDocumentSeries` de `auto/backend/app/modules/tenants/models.py` + o `allocate_fiscal_number` de `tenants/service.py` COM o contrato no-commit + `populate_existing` + o guard retroativo `next_number > max(numero)`), com a tabela de adaptações. Migrations + real tests (INSERT cru). Constraints preservadas: UNIQUE chave_acesso; índice parcial único cert-vivo-por-branch_ref; UNIQUE (product_id, tenant_ref, branch_ref, document_model, serie) na série.
|
||||
- [ ] AST guard portado (`tests/test_for_update_populate_existing.py` adaptado ao src novo). Commit.
|
||||
|
||||
### Task 4: certificados + séries (API v1)
|
||||
|
||||
**Files:** `src/fiscal_svc/certificates/{service,router}.py` (portar `auto/.../fiscal/certificate.py` + rotas: POST/GET/DELETE `/v1/certificados` por branch_ref+cnpj; Fernet com env `FISCAL_CERT_ENCRYPTION_KEY` própria; validações intactas: senha/CNPJ — agora contra o `cnpj` do payload —, vencido, not-yet-valid, replace atômico, corrida→409), `src/fiscal_svc/series/router.py` (POST/GET/PATCH `/v1/series`), `tests/`.
|
||||
- [ ] Testes portados do auto (test_certificate, test_fiscal_series guard) adaptados. Cross-product/tenant → 404 anti-oracle. Commit.
|
||||
|
||||
### Task 5: emissão v1 (paridade 1b.1) + idempotência
|
||||
|
||||
**Files:** `src/fiscal_svc/emission/{schemas,service,router}.py` — portar `auto/.../fiscal/emissao.py` com as adaptações: input é `EmissaoRequest` = `DadosEmissaoPayload` (pydantic espelhando o `DadosEmissao` da lib + `tenant_ref/branch_ref` + `ver_proc` OBRIGATÓRIO + `serie` + `document_model`) — o serviço NÃO monta dados de venda (decisão 3: recebe pronto), só valida completude estrutural (os mesmos 409 `fiscal_config_missing`), resolve NADA (o FiscalResult vem dentro), aloca (série própria), monta chave (cNF do serviço, persistido), `build_nfe` da lib, assina (certificado do branch_ref), persiste ASSINADO num commit. `Idempotency-Key` UNIQUE por (product_id): repetida → 200 existente. Rotas: POST `/v1/emissoes`, GET `/v1/documentos/{id}`, `/xml`, lista com filtros.
|
||||
- [ ] Testes: caminho feliz com goldens da lib como payload-fonte; outbox prova (assinatura sabotada → nNF não queimado); idempotência (2x mesma key → 1 documento, 200); `ver_proc` ausente → 422; XSD válido. Commit.
|
||||
|
||||
### Task 6: os seguros — goldens assinados + contrato HTTP + OpenAPI
|
||||
|
||||
**Files:** `tests/fixtures/test_cert.pfx` (gerado por script commitado, chave de TESTE — nunca real), `tests/test_signed_goldens.py` (para cada golden da lib: emitir via API com dh_emi/cnf pinados + cert de teste → XML ASSINADO determinístico comparado byte a byte com `tests/goldens_signed/*.expected.xml` gerados uma vez — RSA PKCS#1v1.5 é determinístico; fecha o gap da emenda 7b), `tests/test_contract.py` (a suíte de contrato: SÓ httpx contra o app — status codes, shapes, códigos 409 — zero import do interior; é a suíte que uma implementação Go teria que passar), `docs/openapi-v1.json` + `tests/test_openapi_committed.py` (gerado == commitado, senão falha mandando regenerar).
|
||||
- [ ] Suíte completa verde no pod. Commit. Tag `v0.1.0` do serviço.
|
||||
|
||||
---
|
||||
|
||||
## Após as tasks
|
||||
1. Review Opus (porte fiel? adaptações corretas? idempotência sem corrida?) + **Fable** (spec F2 vs construído; drift de porte).
|
||||
2. Deploy dev: Deployment+Service ClusterIP no namespace `autopecas-dev` (por ora — namespace próprio quando um 2º produto chegar), Secret próprio (`fiscal-svc-secrets`: Fernet key NOVA + database URL), NetworkPolicy. Smoke: health + emissão de teste via port-forward.
|
||||
3. Kanban (card dd1fa67c → F2) + memória. Próximo: F3 (corte do auto).
|
||||
@@ -0,0 +1,112 @@
|
||||
# sowai-fiscal-svc — serviço fiscal compartilhado — Design
|
||||
|
||||
**Data:** 2026-07-17 · **Decisão de origem:** Estágio 2 / Caminho B (Jonatan, 2026-07-17).
|
||||
**Onde este spec vive:** no repo do auto (primeiro consumidor) até o repo do serviço
|
||||
existir; então é copiado para lá e este vira ponteiro.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Emissão fiscal (NF-e 55 agora; NFS-e Nacional e NFC-e 65 depois) como **serviço
|
||||
compartilhado** entre produtos SowAI, consumido via REST. A 1b.2 (transporte SEFAZ +
|
||||
DANFE + reconciliador) **nasce dentro dele**. Python **permanente** (a comunidade OCA
|
||||
mantém o leiaute NT — nfelib/erpbrasil; Go descartado para este serviço por decisão
|
||||
registrada).
|
||||
|
||||
## Decisões batidas (2026-07-17 — não re-litigar)
|
||||
|
||||
1. **Dois repos novos (Gitea):** `sowai-fiscal` (lib — núcleo puro extraído do auto:
|
||||
`resolver`, `xml_builder`, `chave_acesso`, `domains`, `presets`, `tpag`; versionada
|
||||
SemVer; **EMENDA F1 2026-07-17: consumida via `git+https@tag` — pinada por hash no
|
||||
lock — em vez de registry PyPI do Gitea**; o registry fica como opção futura se o nº
|
||||
de consumidores crescer. Consequência operacional: `git` precisa existir nas imagens
|
||||
que rodam `uv sync` — Dockerfile do auto ajustado) e `sowai-fiscal-svc` (FastAPI + SQLAlchemy
|
||||
async + Alembic + `uv`, gabarito e convenções do auto — incluindo lock de pod de teste
|
||||
e teste de migration real).
|
||||
2. **Banco:** schema próprio `fiscal_svc` na MESMA instância Postgres do cluster (dev);
|
||||
instância separada é decisão de produção futura. `fiscal_documents`,
|
||||
`fiscal_certificates`, `fiscal_document_series` **mudam de dono**: nascem no serviço
|
||||
via migrations dele; os dados de teste do auto migram por script one-shot; as tabelas
|
||||
do auto são dropadas ao fim do corte.
|
||||
3. **O motor de regras FICA no produto.** `TaxRule`/`TaxProfile`/resolver rodam no auto
|
||||
(config de imposto é do produto); o serviço recebe o **`FiscalResult` já resolvido**
|
||||
dentro do `DadosEmissao`. O serviço valida coerência estrutural (somas, campos
|
||||
obrigatórios do leiaute), nunca recalcula imposto.
|
||||
4. **Tenancy:** `(product_id, tenant_ref, branch_ref)` — refs OPACAS (o serviço não
|
||||
conhece o modelo de org de nenhum produto). O vínculo forte é o **CNPJ** da filial,
|
||||
validado contra o certificado no upload e contra o emitente na emissão.
|
||||
5. **Auth:** API key por produto (`X-Api-Key`, hash argon2/bcrypt no banco, uma por
|
||||
`product_id`), sem ingress público — ClusterIP + NetworkPolicy no namespace.
|
||||
Webhooks assinados com HMAC (segredo por produto).
|
||||
6. **Idempotência:** `Idempotency-Key` obrigatória no `POST /v1/emissoes` (o produto usa
|
||||
`sale_id` + tentativa). Chave repetida → devolve o documento existente (200), nunca
|
||||
emite duas vezes.
|
||||
7. **Os 3 seguros de portabilidade** fazem parte do "pronto" de toda fase:
|
||||
(a) suíte de contrato HTTP language-agnostic (bate na API, não no interior);
|
||||
(b) **corpus golden** `DadosEmissao (JSON) → XML PRÉ-ASSINATURA esperado` como
|
||||
fixtures (**EMENDA F1**: pré-assinatura para determinismo byte a byte com dh_emi/cnf
|
||||
pinados. GAP REGISTRADO: o caminho de ASSINATURA fica sem seguro golden — a F2 DEVE
|
||||
acrescentar goldens assinados com certificado de TESTE fixo do repo, viável porque
|
||||
RSA PKCS#1v1.5 é determinístico dado input+chave iguais);
|
||||
(c) OpenAPI versionado como fonte de verdade do contrato.
|
||||
8. **Invariantes herdados do 1b.1 permanecem intactos DENTRO do serviço:** outbox
|
||||
(alocar nNF + persistir documento num commit), cNF persistido (anti-539), chave
|
||||
módulo-11, certificado Fernet decifrado só em memória, um certificado vivo por
|
||||
branch_ref (índice parcial único), imutabilidade pós-ASSINADO, fail-closed com 409
|
||||
estruturado.
|
||||
9. **Transmissão (1b.2, dentro do serviço):** síncrona `indSinc=1` (SVRS rejeita lote
|
||||
assíncrono de 1 — Rej. 452), timeout → retransmitir o MESMO XML → 204 →
|
||||
`NfeConsultaProtocolo`; reconciliador = CronJob k8s → endpoint interno varrendo
|
||||
`TRANSMITINDO`/`PENDENTE_CONSULTA`. Homologação primeiro, com o **A1 da própria
|
||||
SowAI** (CNPJ 63329985000113; senha só o Jonatan digita, no upload).
|
||||
10. **Auto consome via port `FiscalEmitter`:** duas implementações — `InProcessEmitter`
|
||||
(a atual, morre no corte) e `HttpEmitter` (client do serviço). Corte por flag/env;
|
||||
rollback = voltar a flag.
|
||||
|
||||
## API v1 (contrato)
|
||||
|
||||
| Rota | O quê |
|
||||
|---|---|
|
||||
| `POST /v1/emissoes` | `DadosEmissao` completo + `Idempotency-Key`. Fluxo: valida → aloca → monta → assina → persiste (ASSINADO) → transmite (1b.2) → devolve estado final (`AUTORIZADA`/`REJEITADA`) ou `PENDENTE_CONSULTA` (timeout). Erros de config → 409 estruturado (`fiscal_config_missing` etc., os mesmos códigos do 1b.1). |
|
||||
| `GET /v1/documentos/{id}` | estado + metadados (chave, número, protocolo, rejeição). |
|
||||
| `GET /v1/documentos/{id}/xml` · `/danfe` | XML assinado/autorizado · DANFE PDF (1b.2). |
|
||||
| `GET /v1/documentos?tenant_ref=&branch_ref=&status=` | listagem paginada. |
|
||||
| `POST /v1/certificados` (por `branch_ref`) · `GET` (metadados) · `DELETE` | ciclo do A1 (as validações do 1b.1: senha/CNPJ/validade). |
|
||||
| `POST /v1/series` · `GET` · `PATCH` | numeração por `branch_ref`+modelo (guard retroativo `next_number > max(numero)` vem junto). |
|
||||
| `POST /v1/webhooks` | registro de callback por produto; eventos `documento.status_changed` com HMAC; retry com backoff; o produto mantém polling de reconciliação como fallback. |
|
||||
|
||||
`DadosEmissao` v1 = o dataclass do 1b.1 congelado (emitente, destinatário,
|
||||
itens+FiscalResult por item, pagamento, ambiente) + `product_id/tenant_ref/branch_ref`.
|
||||
Congelar nomes = contrato; mudanças = v2.
|
||||
|
||||
## Sequência de entrega (cada fase = plano próprio, ciclo completo com Fable)
|
||||
|
||||
1. **F1 — lib `sowai-fiscal`:** extração do núcleo puro (o guard `test_fiscal_core_purity`
|
||||
do auto garante que continua extraível) + **goldens gerados do 1b.1 atual** (N casos:
|
||||
Padrão intra/inter, ST, devolução, com/sem UB) + publicação no registry. Auto passa a
|
||||
depender da lib (deleta o código duplicado).
|
||||
2. **F2 — serviço com paridade 1b.1** (pronto inclui: OpenAPI **commitado como
|
||||
artefato versionado** — o seguro (c) — e os goldens ASSINADOS do gap da emenda 7b;
|
||||
`ver_proc` vira parâmetro por produto no contrato, não default da lib): repo, schema `fiscal_svc`, migrations, tenancy,
|
||||
API v1 (sem transmissão), certificados, séries. **Prova de paridade: os goldens do F1
|
||||
passam byte a byte.** Suíte de contrato HTTP nasce aqui.
|
||||
3. **F3 — corte do auto:** `HttpEmitter` atrás do port, migração one-shot dos dados de
|
||||
teste, flag virada, tabelas fiscais do auto dropadas (migration), frontend intocado
|
||||
(o contrato do auto com o frontend não muda — o auto proxia).
|
||||
4. **F4 — 1b.2 no serviço:** transporte SEFAZ homologação (SVRS), estados
|
||||
AUTORIZADA/REJEITADA/PENDENTE_CONSULTA, reconciliador CronJob, DANFE, webhooks.
|
||||
Validação de ponta: primeira nota AUTORIZADA em homologação com o A1 da SowAI.
|
||||
5. **F5 — NFS-e Nacional no serviço** (spec próprio; deadline do cliente 01/09).
|
||||
|
||||
## Fora de escopo (deste spec)
|
||||
NFC-e/contingência (1b.4), eventos (1b.3), cálculo IBS/CBS (R1 — fica no motor do
|
||||
produto), produção SEFAZ (após homologação validada), multi-região.
|
||||
|
||||
## Riscos e mitigações
|
||||
- **Paridade de XML na extração** → goldens byte a byte (F1/F2) — regressão fiscal é
|
||||
impossível de passar silenciosa.
|
||||
- **Corte do auto** → port + flag + rollback trivial; dados de teste migrados por script
|
||||
idempotente conferido por contagem+chaves.
|
||||
- **Webhook perdido** → polling de reconciliação no produto (o status cacheado na venda
|
||||
nunca é a fonte de verdade; a fonte é o serviço).
|
||||
- **Reforma/NTs** → a lib versionada absorve upgrades de nfelib; produtos sobem a lib,
|
||||
o serviço sobe primeiro.
|
||||
@@ -0,0 +1,45 @@
|
||||
[project]
|
||||
name = "sowai-fiscal-svc"
|
||||
version = "0.1.0"
|
||||
description = "SowAI shared fiscal service: NF-e/NFC-e/NFS-e issuance as a multi-product REST API"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"alembic>=1.18.5",
|
||||
"asyncpg>=0.31.0",
|
||||
"bcrypt<4.1",
|
||||
"cryptography>=49.0.0",
|
||||
"erpbrasil-assinatura>=1.8.0",
|
||||
"fastapi>=0.139.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"pydantic-settings>=2.0",
|
||||
"pydantic>=2.13.4",
|
||||
"sowai-fiscal",
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"uvicorn[standard]>=0.49.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
asyncio_default_test_loop_scope = "session"
|
||||
|
||||
[tool.uv.sources]
|
||||
sowai-fiscal = { git = "https://git.sowai.com.br/jonatan/sowai-fiscal.git", rev = "v0.1.0" }
|
||||
|
||||
# src-layout (mirrors sowai-fiscal's own pyproject.toml): `fiscal_svc` is
|
||||
# installed editable by `uv sync` via hatchling, so `import fiscal_svc`
|
||||
# works regardless of cwd (Docker's deps-cache layer runs `uv sync
|
||||
# --no-install-project` BEFORE `COPY . .`, then a second `uv sync` after —
|
||||
# see the Dockerfile's comment for why).
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/fiscal_svc"]
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Ported from `auto/backend/app/core/config.py` (Task 1). Same shape:
|
||||
`pydantic-settings` reading `.env` + env vars, `extra="ignore"` so a
|
||||
product-specific env var set alongside this service's own (e.g. in a
|
||||
shared k8s namespace) never trips validation.
|
||||
|
||||
Adaptation per the porte table (2026-07-17-fiscal-svc-f2-servico.md):
|
||||
this service has NO JWT auth of its own (`require_product` in Task 2
|
||||
reads a bcrypt-hashed API key straight from the `products` table, not a
|
||||
JWT keypair) -- so the `jwt_private_key_path`/`jwt_public_key_path`/
|
||||
`access_token_expire_minutes`/`refresh_token_expire_days` fields the
|
||||
auto's `Settings` carries have no equivalent here and are deliberately
|
||||
NOT ported. `FISCAL_CERT_ENCRYPTION_KEY` (Task 4's Fernet key for
|
||||
certificate ciphertext) is likewise read straight from `os.environ` at
|
||||
the point of use, never through this class -- same "secrets don't live
|
||||
in Settings" convention as the auto's own
|
||||
`FISCAL_CERT_ENCRYPTION_KEY`/`INTEGRACOES_ENCRYPTION_KEY` (see the auto's
|
||||
`app.core.config.Settings` module comment)."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
app_name: str = "sowai-fiscal-svc"
|
||||
database_url: str = (
|
||||
"postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_dev"
|
||||
)
|
||||
test_database_url: str = (
|
||||
"postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_test"
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,19 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from fiscal_svc.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,10 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fiscal_svc.core.config import settings
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
|
||||
|
||||
@app.get("/v1/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,36 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from fiscal_svc.core.config import settings
|
||||
from fiscal_svc.core.db import Base
|
||||
|
||||
# Ported verbatim from `auto/backend/tests/conftest.py` (Task 1). Note:
|
||||
# pytest-asyncio >=0.23 runs the whole test session on a single event loop
|
||||
# when asyncio_default_fixture_loop_scope/asyncio_default_test_loop_scope are
|
||||
# set to "session" (see pyproject.toml [tool.pytest.ini_options]) -- that
|
||||
# replaces the old pattern of redefining the `event_loop` fixture, which no
|
||||
# longer controls fixture/test loop assignment on pytest-asyncio 1.x and
|
||||
# caused a "Future attached to a different loop" RuntimeError when the
|
||||
# session-scoped `test_engine` below was created on a different loop than
|
||||
# each test.
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def test_engine():
|
||||
engine = create_async_engine(settings.test_database_url, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
@@ -0,0 +1,13 @@
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
import pytest
|
||||
|
||||
from fiscal_svc.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_ok():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/v1/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
Reference in New Issue
Block a user