Testability — /v1/test/* endpoints
Sete endpoints exclusivos pra sandbox/teste. Gated por env ENABLE_TEST_API=YES. Em prod (env ≠ YES) todos retornam 404. Mesma auth Authorization: Bearer que endpoints regulares.
Design principles
Seção intitulada “Design principles”- Idempotência total — chamar 2× == 1×, sem estado parasita.
- Sem hit Omie real por default —
seed-ordercria registro em DB local + Bubble Data API; não cria nada na Omie atésync-omieser chamado depois. - Trial-only operations —
resetscopetrial-dataclica botão Omie via headless. Recusa 403 seOMIE_APP_KEYfor produtivo. - Structured logs — todo endpoint emite event
test.{action}.{result}via structlog. - Bubble-friendly responses — JSON flat, sem nested >2 níveis, campos snake_case.
Schema convention
Seção intitulada “Schema convention”Sucesso:
{ "ok": true, "data": { ... }, "elapsed_ms": 42, "test_request_id": "tr-1779466047-abc123"}Erro:
{ "ok": false, "error": { "code": "FORBIDDEN_IN_PROD", "message": "..." }, "elapsed_ms": 3, "test_request_id": "tr-..."}1. POST /v1/test/seed-order
Seção intitulada “1. POST /v1/test/seed-order”Cria order de teste em Bubble + records auxiliares no DB local. Templates pré-definidos cobrem cenários A1–A6 + stress.
Templates
Seção intitulada “Templates”| ID | Cenário | Notes |
|---|---|---|
happy-pj | Cliente PJ, evento SP, valor 1500 | A1 base |
happy-pf | Cliente PF, evento RJ | A2 base |
cancel-flow | PJ + flag expected_cancel: true | A3/A6 setup |
ban-trigger | Cria 5 orders consecutivos, força throttle | stress |
city-no-homologation | Cidade sem NFS-e (ex: pequena interior) | A4 fail-mode |
large-qty | 100 participantes | edge case |
Request
Seção intitulada “Request”POST /v1/test/seed-orderAuthorization: Bearer <token>Content-Type: application/json
{ "template": "happy-pj", "overrides": { "valor": 2500, "cidade": "São Paulo" }}Response
Seção intitulada “Response”{ "ok": true, "data": { "bubble_order_id": "test-1779466047-a3f", "omie_company": "AABC", "template_used": "happy-pj", "expected_status_after_sync": "synced", "fields": { "cliente_nome": "Teste PJ Ltda", "cnpj": "12345678000195", "evento_sigla": "TEST", "valor": 2500, "cidade": "São Paulo", "data": "2026-06-15" }, "bubble_data_api_url": "https://eventos.blueprintt.co/version-test/api/1.1/obj/order/test-1779466047-a3f" }, "elapsed_ms": 412}400 INVALID_TEMPLATE— template desconhecido503 BUBBLE_UNREACHABLE— Bubble Data API offline403 FORBIDDEN_IN_PROD— env gate
2. POST /v1/test/force-state
Seção intitulada “2. POST /v1/test/force-state”Força order em estado específico sem esperar workflow real completar. Útil pra validar branches de UI Bubble.
Request
Seção intitulada “Request”POST /v1/test/force-state{ "bubble_order_id": "test-...", "target": "synced" | "syncing" | "error" | "banned", "reason_override": "manual test trigger"}Behavior
Seção intitulada “Behavior”synced— escreve action_log + omie_id_map fake + PATCH Bubbleorder.status="synced"syncing— escreve in-flight record (não persiste após restart, simula transient)error— registra action_log comerror_reason+ PATCH Bubbleorder.status="error"banned— circuit_breaker força open + PATCH Bubbleorder.status="banned"
Response
Seção intitulada “Response”{ "ok": true, "data": { "bubble_order_id": "...", "from_state": "pending", "to_state": "error", "actions_taken": ["wrote action_log", "patched bubble"] }}3. POST /v1/test/trigger-background
Seção intitulada “3. POST /v1/test/trigger-background”Dispara jobs ARQ manualmente, sem esperar cron.
Request
Seção intitulada “Request”POST /v1/test/trigger-background{ "job": "billing-dia25" | "cleanup-scheduler" | "park-retry" | "matrix-run", "args": { ... job-specific ... }}Behavior
Seção intitulada “Behavior”billing-dia25— força billing scheduler agora (normal: dia 25 do mês)cleanup-scheduler— força action_log retention + idempotency_cache TTL cleanuppark-retry— força resume de jobs parkedmatrix-run— dispararun_matrix_with_parkpara umscenario_id
Response
Seção intitulada “Response”{ "ok": true, "data": { "job_name": "billing-dia25", "arq_job_id": "billing-dia25-1779466047", "scheduled_for": "2026-05-22T11:30:00Z", "queue_depth_after": 3 }}4. POST /v1/test/simulate-omie-webhook
Seção intitulada “4. POST /v1/test/simulate-omie-webhook”Replay ou sintetiza evento Omie inbound pra testar handlers downstream sem esperar evento real.
Request
Seção intitulada “Request”POST /v1/test/simulate-omie-webhook{ "event": "nfse.autorizada" | "nfse.cancelada" | "os.faturada" | "os.cancelada", "bubble_order_id": "test-...", "payload_overrides": { ... }}Behavior
Seção intitulada “Behavior”- Constrói payload Omie-shaped baseado em template do evento
- Chama internamente o webhook handler como se viesse externo
- Sem HTTP loop (chama função handler direto)
Response
Seção intitulada “Response”{ "ok": true, "data": { "event_simulated": "nfse.autorizada", "handler_response": { ... }, "downstream_effects": ["patched bubble", "wrote invoice_ledger"] }}5. POST /v1/test/reset
Seção intitulada “5. POST /v1/test/reset”Wipe state. Scopes selecionáveis. Útil entre runs de teste.
Request
Seção intitulada “Request”POST /v1/test/reset{ "scope": "trial-data" | "idempotency-cache" | "action-log" | "all", "dry_run": false}Behavior por scope
Seção intitulada “Behavior por scope”trial-data— headless clica “Limpar dados de demonstração” no Omie ERP. Recusa 403 seOMIE_APP_KEYfor produtivo (verifica via meta endpoint).idempotency-cache— DELETE FROMidempotency_cacheno Postgres.action-log— DELETE FROMaction_logWHEREcreated_at < now() - 1h(mantém histórico recente).all— todos acima.
Response
Seção intitulada “Response”{ "ok": true, "data": { "scope": "all", "actions": [ {"name": "trial-data clear", "removed": 14, "via": "headless"}, {"name": "idempotency-cache flush", "removed": 47}, {"name": "action-log trim", "removed": 230} ] }, "elapsed_ms": 8350}- Endpoint nunca toca
trial-dataseOMIE_APP_KEYcorresponder a app produtivo (whitelist em config). dry_run: true— lista o que faria sem executar.
6. POST /v1/test/dry-run
Seção intitulada “6. POST /v1/test/dry-run”Simula chamada de qualquer endpoint sem hitar Omie. Tracing total. Sem custo de rate-limit.
Request
Seção intitulada “Request”POST /v1/test/dry-run{ "endpoint": "/v1/orders/{id}/sync-omie", "method": "POST", "path_params": {"id": "test-..."}, "payload": { ... }}Behavior
Seção intitulada “Behavior”- Resolve endpoint → executa internamente até a barreira “Omie call”
- No lugar de chamar Omie, retorna o payload que seria enviado + a resposta esperada (template)
- Útil pra Bubble dev verificar shape de payload sem custo
Response
Seção intitulada “Response”{ "ok": true, "data": { "endpoint": "/v1/orders/{id}/sync-omie", "would_call_omie": [ {"call": "IncluirCliente", "param": {...}}, {"call": "IncluirProjeto", "param": {...}}, {"call": "IncluirOS", "param": {...}} ], "would_return": { "status": "synced", "omie_ids": "..." }, "no_actual_state_change": true }}7. GET /v1/test/health-deep
Seção intitulada “7. GET /v1/test/health-deep”Estado completo: trial banned + last-call timestamps + throttle counters + cache hits + queue depth. Tudo num único JSON.
Request
Seção intitulada “Request”GET /v1/test/health-deepAuthorization: Bearer <token>Response
Seção intitulada “Response”{ "ok": true, "data": { "timestamp": "2026-05-22T11:42:30Z", "components": { "db": {"status": "ok", "latency_ms": 4}, "omie_clients": { "AABC": { "status": "throttled", "last_call_at": "2026-05-22T11:42:28Z", "next_call_eligible_at": "2026-05-22T11:42:30Z", "circuit_state": "closed", "consecutive_failures": 0 } }, "bubble_data_api": {"status": "ok", "latency_ms": 132}, "idempotency_cache": {"entries": 47, "hit_rate_5m": 0.83}, "action_log": {"entries_last_1h": 230}, "arq_queue": {"depth": 0, "workers_alive": 1} }, "trial_status": { "is_blocked": false, "ban_expires_at": null, "last_redundant_at": "2026-05-22T11:30:00Z" }, "version": {"sha": "8959ad0", "build_at": "2026-05-22T10:55Z"} }}Bubble usage pattern
Seção intitulada “Bubble usage pattern”Bubble cron hourly → GET /v1/test/health-deep → if ok=false OR trial_status.is_blocked → alert PM via Bubble emailEnv gate
Seção intitulada “Env gate”from fastapi import APIRouter, Depends, HTTPException
router = APIRouter(prefix="/v1/test", tags=["testability"])
def require_test_api_enabled(): if os.environ.get("ENABLE_TEST_API") != "YES": raise HTTPException(status_code=404) return True
@router.post("/seed-order", dependencies=[Depends(require_test_api_enabled)])def seed_order(...): ....env.example:
# Enable /v1/test/* sandbox endpoints (sandbox/staging only — NEVER in prod)ENABLE_TEST_API=YESBubble dev consumption pattern
Seção intitulada “Bubble dev consumption pattern”[Bubble admin "Sandbox Console" page] ├── Button: Seed (template selector) → POST /v1/test/seed-order ├── Last seeded order_id: <text> ├── Button: Sync this → POST /v1/orders/{id}/sync-omie ├── Button: Verify → GET /v1/orders/{id}/status ├── Button: Force error → POST /v1/test/force-state ├── Button: Dry-run → POST /v1/test/dry-run ├── Button: Reset all → POST /v1/test/reset {scope: "all"} └── Live JSON: GET /v1/test/health-deep (auto-refresh 30s)Substitui “torcer para funcionar” por “controlar e ver”.
Ver também: Bubble Dev — Fases de integração (Fase 1 usa estes endpoints).