From f93afd06460e61642de7a51e75a9f2fdbdb7be19 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 8 Mar 2026 01:12:32 +0100 Subject: [PATCH] test: add coverage for none check method, re-scan update, icon registry --- backend/tests/test_scan.py | 80 ++++++++++++++++- backend/tests/test_status_checker.py | 16 ++++ frontend/src/types/__tests__/types.test.ts | 12 +++ .../src/utils/__tests__/nodeIcons.test.ts | 85 +++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/__tests__/nodeIcons.test.ts diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 1e8c0b6..37c8708 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -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) diff --git a/backend/tests/test_status_checker.py b/backend/tests/test_status_checker.py index da9b3b3..1824a99 100644 --- a/backend/tests/test_status_checker.py +++ b/backend/tests/test_status_checker.py @@ -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) diff --git a/frontend/src/types/__tests__/types.test.ts b/frontend/src/types/__tests__/types.test.ts index c522773..c8685c6 100644 --- a/frontend/src/types/__tests__/types.test.ts +++ b/frontend/src/types/__tests__/types.test.ts @@ -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) + }) +}) diff --git a/frontend/src/utils/__tests__/nodeIcons.test.ts b/frontend/src/utils/__tests__/nodeIcons.test.ts new file mode 100644 index 0000000..7824128 --- /dev/null +++ b/frontend/src/utils/__tests__/nodeIcons.test.ts @@ -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) + }) +})