test: add coverage for none check method, re-scan update, icon registry

This commit is contained in:
Pouzor
2026-03-08 01:12:32 +01:00
parent b2a6651db7
commit f93afd0646
4 changed files with 192 additions and 1 deletions
+79 -1
View File
@@ -1,10 +1,14 @@
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingDevice
from app.db.models import PendingDevice, ScanRun
from app.services.scanner import run_scan
@pytest.fixture
@@ -158,3 +162,77 @@ async def test_list_runs_empty(client: AsyncClient, headers):
res = await client.get("/api/v1/scan/runs", headers=headers)
assert res.status_code == 200
assert res.json() == []
# --- run_scan: re-scan updates existing pending devices ---
MOCK_HOST = {
"ip": "192.168.1.50",
"mac": "aa:bb:cc:dd:ee:ff",
"hostname": "myhost.lan",
"os": "Linux",
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
}
@pytest.mark.asyncio
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
device = result.scalar_one_or_none()
assert device is not None
assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services)
assert device.suggested_type == "server"
@pytest.mark.asyncio
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate."""
# Pre-existing pending device with no services
existing = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(existing)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
# Should still be only one device
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
devices = list(result.scalars().all())
assert len(devices) == 1
device = devices[0]
# Services and hostname should be updated
assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services)
+16
View File
@@ -7,6 +7,22 @@ from app.services.status_checker import _tcp_connect, check_node
# --- check_node dispatcher ---
@pytest.mark.asyncio
async def test_check_node_none_always_online():
result = await check_node("none", None, None)
assert result["status"] == "online"
assert result["response_time_ms"] is None
@pytest.mark.asyncio
async def test_check_node_none_always_online_ignores_host():
"""'none' returns online immediately without any network call."""
with patch("app.services.status_checker._ping", new_callable=AsyncMock) as mock_ping:
result = await check_node("none", None, "192.168.1.1")
mock_ping.assert_not_called()
assert result["status"] == "online"
@pytest.mark.asyncio
async def test_check_node_unknown_without_host():
result = await check_node("ping", None, None)
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { NODE_TYPE_LABELS, STATUS_COLORS, EDGE_TYPE_LABELS } from '@/types'
import type { CheckMethod } from '@/types'
describe('NODE_TYPE_LABELS', () => {
it('has an entry for every node type', () => {
@@ -33,3 +34,14 @@ describe('EDGE_TYPE_LABELS', () => {
})
})
})
describe('CheckMethod', () => {
it('includes none as a valid check method', () => {
const methods: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
// All values are valid CheckMethod — this is a compile-time type check;
// runtime test just ensures the array is well-formed
expect(methods).toContain('none')
expect(methods).toContain('ping')
expect(methods.length).toBe(8)
})
})
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { Globe } from 'lucide-react'
import { ICON_REGISTRY, ICON_CATEGORIES, ICON_MAP, resolveNodeIcon } from '../nodeIcons'
describe('ICON_REGISTRY', () => {
it('has entries', () => {
expect(ICON_REGISTRY.length).toBeGreaterThan(0)
})
it('every entry has required fields', () => {
for (const entry of ICON_REGISTRY) {
expect(typeof entry.key).toBe('string')
expect(entry.key.length).toBeGreaterThan(0)
expect(typeof entry.label).toBe('string')
expect(typeof entry.category).toBe('string')
expect(entry.icon).toBeTruthy()
}
})
it('all keys are unique', () => {
const keys = ICON_REGISTRY.map((e) => e.key)
expect(new Set(keys).size).toBe(keys.length)
})
it('contains expected well-known icons', () => {
const keys = ICON_REGISTRY.map((e) => e.key)
expect(keys).toContain('home') // Home Assistant
expect(keys).toContain('play') // Jellyfin
expect(keys).toContain('shield') // Pi-hole
expect(keys).toContain('anchor') // Portainer
expect(keys).toContain('key') // Vaultwarden
expect(keys).toContain('database') // DB services
})
})
describe('ICON_CATEGORIES', () => {
it('has at least one category', () => {
expect(ICON_CATEGORIES.length).toBeGreaterThan(0)
})
it('every entry in ICON_REGISTRY belongs to a known category', () => {
for (const entry of ICON_REGISTRY) {
expect(ICON_CATEGORIES).toContain(entry.category)
}
})
})
describe('ICON_MAP', () => {
it('contains an entry for every registry key', () => {
for (const entry of ICON_REGISTRY) {
expect(ICON_MAP[entry.key]).toBeDefined()
expect(ICON_MAP[entry.key]).toBe(entry.icon)
}
})
it('returns undefined for unknown keys', () => {
expect(ICON_MAP['__nonexistent__']).toBeUndefined()
})
})
describe('resolveNodeIcon', () => {
it('returns typeIcon when no custom_icon set', () => {
expect(resolveNodeIcon(Globe)).toBe(Globe)
})
it('returns typeIcon when custom_icon is undefined', () => {
expect(resolveNodeIcon(Globe, undefined)).toBe(Globe)
})
it('returns typeIcon when custom_icon key is unknown', () => {
expect(resolveNodeIcon(Globe, '__nonexistent__')).toBe(Globe)
})
it('returns the custom icon when key is valid', () => {
const homeEntry = ICON_REGISTRY.find((e) => e.key === 'home')!
expect(resolveNodeIcon(Globe, 'home')).toBe(homeEntry.icon)
})
it('custom icon overrides typeIcon', () => {
const playEntry = ICON_REGISTRY.find((e) => e.key === 'play')!
const result = resolveNodeIcon(Globe, 'play')
expect(result).toBe(playEntry.icon)
expect(result).not.toBe(Globe)
})
})