feat(sync): /api/sync/health endpoint + dashboard health pill + MALFORMED UI
Backend:
- GET /api/sync/health returns {last_sync_at, last_sync_status,
last_halt_reason, recent_phase_failures, escalation_phase, is_healthy}.
healthy when last run was completed (or none yet), no phase has
tripped the 3-in-a-row escalation, and recent failures <= 1.
- Dashboard + run-level endpoints include `malformed` count so the
Defecte pill can render.
Frontend:
- Health pill in .sync-card-controls with three states — healthy
(success green, check icon), warning (amber, triangle), escalated
(error red, x-octagon + glow). Tooltip exposes the halt reason and
the top phases with recent failures.
- Status-dot + badge add MALFORMED treatment via --compare orange,
distinct from ERROR red. DESIGN.md notes the diagnostic rationale
(ERROR = runtime, MALFORMED = payload source issue).
- Defecte filter pill on dashboard + logs pages. Mobile segmented
control includes Defecte count. Counts wired to the malformed key.
- startSync() shows a native confirm modal when state is
halted_escalation — operator override still possible, not silenced.
- ORDER_STATUS.MALFORMED mirror added to shared.js.
- Cache-bust: style.css v46, shared.js v47, dashboard.js v52,
logs.js v16.
5 endpoint tests cover empty state, completed, failed, escalated,
single-failure warning. Full CI: 257 unit + 33 e2e green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -160,6 +160,52 @@ async def sync_status():
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/sync/health")
|
||||
async def sync_health():
|
||||
"""Aggregated sync health snapshot used by the dashboard pill.
|
||||
|
||||
Fields:
|
||||
last_sync_at ISO timestamp of most recent run start (or null).
|
||||
last_sync_status completed | failed | running | halted_escalation | null.
|
||||
last_halt_reason error_message from that run (only populated on
|
||||
failed / halted_escalation).
|
||||
recent_phase_failures {phase: count} across the last 3 runs.
|
||||
escalation_phase the phase that tripped the 3-in-a-row halt, or null.
|
||||
is_healthy completed last + <=1 recent phase failure.
|
||||
"""
|
||||
db = await sqlite_service.get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT run_id, started_at, status, error_message "
|
||||
"FROM sync_runs ORDER BY started_at DESC LIMIT 1"
|
||||
)
|
||||
last_row = await cursor.fetchone()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
last = dict(last_row) if last_row else {}
|
||||
last_status = last.get("status")
|
||||
halt_reason = last.get("error_message") if last_status in ("failed", "halted_escalation") else None
|
||||
|
||||
counts = await sqlite_service.get_recent_phase_failures(limit=3)
|
||||
escalation_phase = next((p for p, c in counts.items() if c >= 3), None)
|
||||
|
||||
is_healthy = (
|
||||
last_status in (None, "completed")
|
||||
and escalation_phase is None
|
||||
and sum(counts.values()) <= 1
|
||||
)
|
||||
|
||||
return {
|
||||
"last_sync_at": last.get("started_at"),
|
||||
"last_sync_status": last_status,
|
||||
"last_halt_reason": halt_reason,
|
||||
"recent_phase_failures": counts,
|
||||
"escalation_phase": escalation_phase,
|
||||
"is_healthy": is_healthy,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/sync/history")
|
||||
async def sync_history(page: int = 1, per_page: int = 20):
|
||||
"""Get sync run history."""
|
||||
|
||||
@@ -1000,6 +1000,7 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
|
||||
"error": status_counts.get(OrderStatus.ERROR.value, 0),
|
||||
"already_imported": status_counts.get(OrderStatus.ALREADY_IMPORTED.value, 0),
|
||||
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
|
||||
"malformed": status_counts.get(OrderStatus.MALFORMED.value, 0),
|
||||
"total": sum(status_counts.values())
|
||||
}
|
||||
}
|
||||
@@ -1135,6 +1136,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
||||
"skipped": status_counts.get(OrderStatus.SKIPPED.value, 0),
|
||||
"error": status_counts.get(OrderStatus.ERROR.value, 0),
|
||||
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
|
||||
"malformed": status_counts.get(OrderStatus.MALFORMED.value, 0),
|
||||
"total": sum(status_counts.values()),
|
||||
"uninvoiced_sqlite": uninvoiced_sqlite,
|
||||
"uninvoiced_old": uninvoiced_old,
|
||||
|
||||
@@ -495,6 +495,7 @@ input[type="checkbox"] {
|
||||
.dot-yellow { background: var(--warning); box-shadow: 0 0 6px 2px rgba(202,138,4,0.3); }
|
||||
.dot-red { background: var(--error); box-shadow: 0 0 8px 2px rgba(220,38,38,0.35); }
|
||||
.dot-gray { background: var(--cancelled); }
|
||||
.dot-orange { background: var(--compare); box-shadow: 0 0 8px 2px rgba(234,88,12,0.35); }
|
||||
.dot-blue { background: var(--info); }
|
||||
|
||||
/* ── Flat row (mobile + desktop) ────────────────── */
|
||||
@@ -805,6 +806,45 @@ tr.mapping-deleted td {
|
||||
.sync-status-dot.running { background: var(--info); animation: pulse-dot 2s ease-in-out infinite; }
|
||||
.sync-status-dot.completed { background: var(--success); }
|
||||
.sync-status-dot.failed { background: var(--error); }
|
||||
.sync-status-dot.malformed { background: var(--compare); box-shadow: 0 0 8px 2px rgba(234,88,12,0.35); }
|
||||
|
||||
/* ── Sync health pill (dashboard only) ────────────── */
|
||||
.health-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
min-height: 32px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.health-pill i { font-size: 0.9375rem; line-height: 1; }
|
||||
.health-pill.healthy {
|
||||
background: var(--success-light);
|
||||
color: var(--success-text);
|
||||
border-color: var(--success);
|
||||
}
|
||||
.health-pill.warning {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning-text);
|
||||
border-color: var(--warning);
|
||||
}
|
||||
.health-pill.escalated {
|
||||
background: var(--error-light);
|
||||
color: var(--error-text);
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 8px 2px rgba(220,38,38,0.35);
|
||||
}
|
||||
/* Ensure adequate touch target on mobile */
|
||||
@media (max-width: 600px) {
|
||||
.health-pill { min-height: 44px; padding: 0.5rem 0.75rem; }
|
||||
}
|
||||
|
||||
/* ── Custom period range inputs ──────────────────── */
|
||||
.period-custom-range {
|
||||
|
||||
@@ -162,9 +162,76 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sync Health pill ─────────────────────────────
|
||||
|
||||
let _lastHealth = null;
|
||||
|
||||
async function pollSyncHealth() {
|
||||
try {
|
||||
const data = await fetchJSON('/api/sync/health');
|
||||
_lastHealth = data;
|
||||
renderHealthPill(data);
|
||||
} catch (e) { /* fail-soft: keep last state */ }
|
||||
}
|
||||
|
||||
function renderHealthPill(h) {
|
||||
const pill = document.getElementById('syncHealthPill');
|
||||
if (!pill) return;
|
||||
const icon = pill.querySelector('i');
|
||||
const label = pill.querySelector('.health-pill-label');
|
||||
let state = 'healthy', iconCls = 'bi-check-circle-fill', text = 'Sanatos', tooltip;
|
||||
|
||||
const recent = h.recent_phase_failures || {};
|
||||
const recentCount = Object.values(recent).reduce((a, b) => a + (b || 0), 0);
|
||||
|
||||
if (h.escalation_phase || h.last_sync_status === 'halted_escalation') {
|
||||
state = 'escalated';
|
||||
iconCls = 'bi-x-octagon-fill';
|
||||
text = 'Blocat';
|
||||
tooltip = `Blocat — faza "${h.escalation_phase || '?'}" a esuat 3 sync-uri consecutive.\n`
|
||||
+ `Ultima eroare: ${h.last_halt_reason || '—'}\n`
|
||||
+ `Click Start Sync pentru override manual.`;
|
||||
} else if (h.last_sync_status === 'failed' || recentCount > 0) {
|
||||
state = 'warning';
|
||||
iconCls = 'bi-exclamation-triangle-fill';
|
||||
text = 'Atentie';
|
||||
const topPhases = Object.entries(recent).slice(0, 3)
|
||||
.map(([p, c]) => `${p} (${c} of last 3)`).join(', ');
|
||||
tooltip = `Atentie — ${topPhases || 'sync anterior esuat'}`
|
||||
+ (h.last_halt_reason ? `\nLast error: ${h.last_halt_reason}` : '');
|
||||
} else {
|
||||
const lastAt = h.last_sync_at ? h.last_sync_at.replace('T', ' ').slice(5, 16) : 'nicio rulare';
|
||||
tooltip = `Sanatos — ultimul sync: ${lastAt}`;
|
||||
}
|
||||
|
||||
pill.className = 'health-pill ' + state;
|
||||
pill.setAttribute('aria-label', `Sync: ${text}`);
|
||||
pill.title = tooltip;
|
||||
if (icon) icon.className = 'bi ' + iconCls;
|
||||
if (label) label.textContent = text;
|
||||
}
|
||||
|
||||
function startHealthPolling() {
|
||||
pollSyncHealth();
|
||||
setInterval(pollSyncHealth, 10000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', startHealthPolling);
|
||||
|
||||
// ── Sync Controls ─────────────────────────────────
|
||||
|
||||
async function startSync() {
|
||||
// Escalation override — confirm before overriding the auto-halt
|
||||
if (_lastHealth && (_lastHealth.escalation_phase
|
||||
|| _lastHealth.last_sync_status === 'halted_escalation')) {
|
||||
const phase = _lastHealth.escalation_phase || '?';
|
||||
const reason = _lastHealth.last_halt_reason || '(unknown)';
|
||||
const msg = `⚠ Sync blocat automat\n\n`
|
||||
+ `Faza "${phase}" a esuat in ultimele 3 sync-uri consecutive.\n`
|
||||
+ `Ultima eroare: ${reason}\n\n`
|
||||
+ `Repornesti oricum?`;
|
||||
if (!confirm(msg)) return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/sync/start', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
@@ -174,6 +241,7 @@ async function startSync() {
|
||||
}
|
||||
// Polling will detect the running state — just speed it up immediately
|
||||
pollSyncStatus();
|
||||
pollSyncHealth();
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + err.message);
|
||||
}
|
||||
@@ -329,6 +397,7 @@ async function loadDashOrders() {
|
||||
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
||||
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
|
||||
if (el('cntMal')) el('cntMal').textContent = c.malformed || 0;
|
||||
if (el('cntDiff')) el('cntDiff').textContent = c.diffs || 0;
|
||||
|
||||
// Attention card
|
||||
@@ -415,6 +484,7 @@ async function loadDashOrders() {
|
||||
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
|
||||
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' },
|
||||
{ label: 'Anulate', count: c.cancelled || 0, value: ORDER_STATUS.CANCELLED, active: activeStatus === ORDER_STATUS.CANCELLED, colorClass: 'fc-dark' },
|
||||
{ label: 'Def.', count: c.malformed || 0, value: ORDER_STATUS.MALFORMED, active: activeStatus === ORDER_STATUS.MALFORMED, colorClass: 'fc-orange' },
|
||||
{ label: 'Dif.', count: c.diffs || 0, value: 'DIFFS', active: activeStatus === 'DIFFS', colorClass: 'fc-orange' }
|
||||
], (val) => {
|
||||
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
||||
|
||||
@@ -137,6 +137,8 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
document.getElementById('countError').textContent = counts.error || 0;
|
||||
const alreadyEl = document.getElementById('countAlreadyImported');
|
||||
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
|
||||
const malEl = document.getElementById('countMalformed');
|
||||
if (malEl) malEl.textContent = counts.malformed || 0;
|
||||
|
||||
const tbody = document.getElementById('runOrdersBody');
|
||||
const orders = data.orders || [];
|
||||
@@ -238,7 +240,8 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
{ label: 'Imp.', count: counts.imported || 0, value: ORDER_STATUS.IMPORTED, active: currentFilter === ORDER_STATUS.IMPORTED, colorClass: 'fc-green' },
|
||||
{ label: 'Deja', count: counts.already_imported || 0, value: ORDER_STATUS.ALREADY_IMPORTED, active: currentFilter === ORDER_STATUS.ALREADY_IMPORTED, colorClass: 'fc-blue' },
|
||||
{ label: 'Omise', count: counts.skipped || 0, value: ORDER_STATUS.SKIPPED, active: currentFilter === ORDER_STATUS.SKIPPED, colorClass: 'fc-yellow' },
|
||||
{ label: 'Erori', count: counts.error || 0, value: ORDER_STATUS.ERROR, active: currentFilter === ORDER_STATUS.ERROR, colorClass: 'fc-red' }
|
||||
{ label: 'Erori', count: counts.error || 0, value: ORDER_STATUS.ERROR, active: currentFilter === ORDER_STATUS.ERROR, colorClass: 'fc-red' },
|
||||
{ label: 'Defecte', count: counts.malformed || 0, value: ORDER_STATUS.MALFORMED, active: currentFilter === ORDER_STATUS.MALFORMED, colorClass: 'fc-orange' }
|
||||
], (val) => filterOrders(val));
|
||||
|
||||
// Orders pagination
|
||||
|
||||
@@ -19,6 +19,7 @@ const ORDER_STATUS = Object.freeze({
|
||||
ERROR: 'ERROR',
|
||||
CANCELLED: 'CANCELLED',
|
||||
DELETED_IN_ROA: 'DELETED_IN_ROA',
|
||||
MALFORMED: 'MALFORMED',
|
||||
});
|
||||
|
||||
// ── HTML escaping ─────────────────────────────────
|
||||
@@ -519,6 +520,7 @@ function orderStatusBadge(status) {
|
||||
case ORDER_STATUS.ERROR: return '<span class="badge bg-danger">Eroare</span>';
|
||||
case ORDER_STATUS.CANCELLED: return '<span class="badge bg-secondary">Anulat</span>';
|
||||
case ORDER_STATUS.DELETED_IN_ROA: return '<span class="badge bg-dark">Sters din ROA</span>';
|
||||
case ORDER_STATUS.MALFORMED: return '<span class="badge" style="background:var(--compare-light);color:var(--compare-text);border:1px solid var(--compare)">Defect</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
@@ -1037,6 +1039,8 @@ function statusDot(status) {
|
||||
case ORDER_STATUS.ERROR:
|
||||
case 'FAILED':
|
||||
return '<span class="dot dot-red"></span>';
|
||||
case ORDER_STATUS.MALFORMED:
|
||||
return '<span class="dot dot-orange" title="Date defecte — escalat la GoMag"></span>';
|
||||
case ORDER_STATUS.CANCELLED:
|
||||
case ORDER_STATUS.DELETED_IN_ROA:
|
||||
return '<span class="dot dot-gray"></span>';
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
{% set rp = request.scope.get('root_path', '') %}
|
||||
<link href="{{ rp }}/static/css/style.css?v=45" rel="stylesheet">
|
||||
<link href="{{ rp }}/static/css/style.css?v=46" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ rp }}/static/js/shared.js?v=46"></script>
|
||||
<script src="{{ rp }}/static/js/shared.js?v=47"></script>
|
||||
<script>
|
||||
// Dark mode toggle
|
||||
function toggleDarkMode() {
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
<div class="sync-card-controls">
|
||||
<span id="syncStatusDot" class="sync-status-dot idle"></span>
|
||||
<span id="syncStatusText" class="text-secondary">Inactiv</span>
|
||||
<span id="syncHealthPill" class="health-pill healthy" role="status"
|
||||
aria-label="Sync sanatos" title="Verificare stare sync">
|
||||
<i class="bi bi-check-circle-fill" aria-hidden="true"></i>
|
||||
<span class="health-pill-label">Sanatos</span>
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="d-flex align-items-center gap-1 text-muted">
|
||||
Auto:
|
||||
@@ -76,6 +81,7 @@
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="INVOICED">Facturate <span class="filter-count fc-green" id="cntFact">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefacturate <span class="filter-count fc-red" id="cntNef">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.CANCELLED.value }}">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.MALFORMED.value }}">Defecte <span class="filter-count fc-orange" id="cntMal">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-status="DIFFS">Diferente <span class="filter-count fc-orange" id="cntDiff">0</span></button>
|
||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">↻</button>
|
||||
</div>
|
||||
@@ -115,5 +121,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=51"></script>
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=52"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.ALREADY_IMPORTED.value }}">Deja imp. <span class="filter-count fc-blue" id="countAlreadyImported">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.SKIPPED.value }}">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.ERROR.value }}">Erori <span class="filter-count fc-red" id="countError">0</span></button>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.MALFORMED.value }}">Defecte <span class="filter-count fc-orange" id="countMalformed">0</span></button>
|
||||
</div>
|
||||
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
|
||||
|
||||
@@ -109,5 +110,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=15"></script>
|
||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=16"></script>
|
||||
{% endblock %}
|
||||
|
||||
110
api/tests/test_sync_health_endpoint.py
Normal file
110
api/tests/test_sync_health_endpoint.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for GET /api/sync/health."""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_tmpdir = tempfile.mkdtemp()
|
||||
os.environ.setdefault("FORCE_THIN_MODE", "true")
|
||||
os.environ.setdefault("SQLITE_DB_PATH", os.path.join(_tmpdir, "test_health.db"))
|
||||
os.environ.setdefault("ORACLE_DSN", "dummy")
|
||||
os.environ.setdefault("ORACLE_USER", "dummy")
|
||||
os.environ.setdefault("ORACLE_PASSWORD", "dummy")
|
||||
os.environ.setdefault("JSON_OUTPUT_DIR", _tmpdir)
|
||||
|
||||
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _api_dir not in sys.path:
|
||||
sys.path.insert(0, _api_dir)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import database
|
||||
from app.services import sqlite_service
|
||||
from app.main import app
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _reset():
|
||||
database.init_sqlite()
|
||||
db = await sqlite_service.get_sqlite()
|
||||
try:
|
||||
await db.execute("DELETE FROM sync_phase_failures")
|
||||
await db.execute("DELETE FROM sync_runs")
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
yield
|
||||
|
||||
|
||||
async def _make_run(run_id: str, status: str = "completed", offset: int = 0,
|
||||
error_message: str | None = None):
|
||||
db = await sqlite_service.get_sqlite()
|
||||
try:
|
||||
await db.execute(
|
||||
"""INSERT INTO sync_runs (run_id, started_at, status, error_message)
|
||||
VALUES (?, datetime('now', ?), ?, ?)""",
|
||||
(run_id, f"{offset} seconds", status, error_message),
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_health_empty_state():
|
||||
r = client.get("/api/sync/health")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["last_sync_at"] is None
|
||||
assert data["last_sync_status"] is None
|
||||
assert data["recent_phase_failures"] == {}
|
||||
assert data["escalation_phase"] is None
|
||||
assert data["is_healthy"] is True
|
||||
|
||||
|
||||
async def test_health_completed_is_healthy():
|
||||
await _make_run("ok-1", status="completed")
|
||||
r = client.get("/api/sync/health")
|
||||
data = r.json()
|
||||
assert data["last_sync_status"] == "completed"
|
||||
assert data["is_healthy"] is True
|
||||
|
||||
|
||||
async def test_health_reports_last_failure():
|
||||
await _make_run("fail-1", status="failed", error_message="boom")
|
||||
r = client.get("/api/sync/health")
|
||||
data = r.json()
|
||||
assert data["last_sync_status"] == "failed"
|
||||
assert data["last_halt_reason"] == "boom"
|
||||
assert data["is_healthy"] is False
|
||||
|
||||
|
||||
async def test_health_detects_escalation():
|
||||
# 3 consecutive runs each with price_sync failure → escalation flagged.
|
||||
for i in range(3):
|
||||
run_id = f"esc-{i}"
|
||||
await _make_run(run_id, status="failed", offset=i,
|
||||
error_message="ESCALATED: phase price_sync failed 3 consecutive runs")
|
||||
await sqlite_service.record_phase_failure(run_id, "price_sync", "IntegrityError: X")
|
||||
|
||||
r = client.get("/api/sync/health")
|
||||
data = r.json()
|
||||
assert data["escalation_phase"] == "price_sync"
|
||||
assert data["is_healthy"] is False
|
||||
assert data["recent_phase_failures"]["price_sync"] == 3
|
||||
assert "ESCALATED" in (data["last_halt_reason"] or "")
|
||||
|
||||
|
||||
async def test_health_one_phase_failure_still_warning_not_healthy():
|
||||
await _make_run("recent-fail", status="completed")
|
||||
await sqlite_service.record_phase_failure("recent-fail", "invoice_check", "err")
|
||||
r = client.get("/api/sync/health")
|
||||
data = r.json()
|
||||
# 1 recent phase failure → is_healthy stays True (<=1 tolerance); healthy
|
||||
assert data["is_healthy"] is True
|
||||
assert data["recent_phase_failures"]["invoice_check"] == 1
|
||||
Reference in New Issue
Block a user