diff --git a/tests/backend/test_display_name_fuzzing.py b/tests/backend/test_display_name_fuzzing.py new file mode 100644 index 0000000..c5ebbcd --- /dev/null +++ b/tests/backend/test_display_name_fuzzing.py @@ -0,0 +1,129 @@ +import base64 +from hypothesis import given, strategies as st +import RNS.vendor.umsgpack as msgpack +from meshchatx.src.backend.meshchat_utils import ( + parse_lxmf_display_name, + parse_nomadnetwork_node_display_name, + parse_lxmf_propagation_node_app_data, + parse_lxmf_stamp_cost, +) + +# Strategies for generating diverse display names +st_display_name = st.one_of( + st.text(), + st.binary(), + st.none(), + st.integers(min_value=-(2**31), max_value=2**31 - 1), + st.floats(allow_nan=False, allow_infinity=False), + st.lists(st.text()), + st.dictionaries(st.text(), st.text()), +) + + +@st.composite +def st_lxmf_announce_app_data(draw): + """Generates valid LXMF announce app_data (msgpack list [name, ...])""" + name = draw(st_display_name) + # LXMF announces are usually [display_name, stamp_cost, propagation_node_data, ...] + # We'll generate lists of various lengths + app_data_list = [name] + extra_count = draw(st.integers(min_value=0, max_value=5)) + app_data_list.extend(draw(st_display_name) for _ in range(extra_count)) + + return msgpack.packb(app_data_list) + + +@given(data=st.one_of(st.binary(), st_lxmf_announce_app_data())) +def test_parse_lxmf_display_name_property_based(data): + # Test with bytes directly + result = parse_lxmf_display_name(data) + assert isinstance(result, str) + + # Test with base64 encoded string + b64_data = base64.b64encode(data).decode("utf-8") + result_b64 = parse_lxmf_display_name(b64_data) + assert isinstance(result_b64, str) + assert result == result_b64 + + +@given(name=st.text()) +def test_parse_nomadnetwork_node_display_name_property_based(name): + # Valid UTF-8 bytes + data = name.encode("utf-8", errors="replace") + result = parse_nomadnetwork_node_display_name(data) + assert isinstance(result, str) + # It might not match perfectly if there were replacement characters, but it should be a string + + # Test with base64 + b64_data = base64.b64encode(data).decode("utf-8") + result_b64 = parse_nomadnetwork_node_display_name(b64_data) + assert isinstance(result_b64, str) + assert result == result_b64 + + +@given(data=st.binary()) +def test_parse_lxmf_display_name_invalid_base64(data): + # Generate something that is NOT valid base64 (by adding invalid chars) + invalid_b64 = base64.b64encode(data).decode("utf-8") + "!!!" + # This should not crash, it should just return the default value or fail gracefully + result = parse_lxmf_display_name(invalid_b64) + assert isinstance(result, str) + + +@given(app_data=st_lxmf_announce_app_data()) +def test_parse_lxmf_display_name_logic_check(app_data): + """Verify that if we can manually unpack it, parse_lxmf_display_name matches our expectation""" + try: + unpacked = msgpack.unpackb(app_data) + if isinstance(unpacked, list) and len(unpacked) >= 1: + expected_raw = unpacked[0] + result = parse_lxmf_display_name(app_data) + + if expected_raw is None: + assert result == "Anonymous Peer" + else: + # Our implementation handles both bytes and strings, and uses errors='replace' + if isinstance(expected_raw, bytes): + expected_str = expected_raw.decode("utf-8", errors="replace") + else: + expected_str = str(expected_raw) + assert result == expected_str + except Exception: + # If msgpack fails to unpack, the function should still return a string + result = parse_lxmf_display_name(app_data) + assert isinstance(result, str) + + +@given(data=st.binary(min_size=0, max_size=10000)) +def test_parse_nomadnetwork_node_display_name_fuzz(data): + # Should never crash even with completely random bytes + result = parse_nomadnetwork_node_display_name(data) + assert isinstance(result, str) + + +@given(name=st.text(min_size=0, max_size=10000)) +def test_parse_lxmf_display_name_very_long(name): + # Test with very long names + app_data = msgpack.packb([name, None, None]) + result = parse_lxmf_display_name(app_data) + assert result == name + assert len(result) == len(name) + + +@given(data=st.binary()) +def test_parse_lxmf_propagation_node_app_data_fuzz(data): + # Should never crash + result = parse_lxmf_propagation_node_app_data(data) + if result is not None: + assert isinstance(result, dict) + assert "enabled" in result + assert "timebase" in result + assert "per_transfer_limit" in result + + +@given(data=st.binary()) +def test_parse_lxmf_stamp_cost_fuzz(data): + # Should never crash + parse_lxmf_stamp_cost(data) + # stamp_cost can be None or an integer (or whatever LXMF returns) + # We just want to ensure no crash diff --git a/tests/backend/test_notifications.py b/tests/backend/test_notifications.py index 3b1ba6d..ca57f04 100644 --- a/tests/backend/test_notifications.py +++ b/tests/backend/test_notifications.py @@ -148,7 +148,7 @@ def mock_app(db, tmp_path): def test_add_get_notifications(db): """Test basic notification storage and retrieval.""" db.misc.add_notification( - type="test_type", + notification_type="test_type", remote_hash="test_hash", title="Test Title", content="Test Content", @@ -225,19 +225,19 @@ def test_voicemail_notification(mock_app): @settings(deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture]) @given( - type=st.text(min_size=1, max_size=50), + notification_type=st.text(min_size=1, max_size=50), remote_hash=st.text(min_size=1, max_size=64), title=st.text(min_size=1, max_size=100), content=st.text(min_size=1, max_size=500), ) -def test_notification_fuzzing(db, type, remote_hash, title, content): +def test_notification_fuzzing(db, notification_type, remote_hash, title, content): """Fuzz notification storage with varied data.""" - db.misc.add_notification(type, remote_hash, title, content) + db.misc.add_notification(notification_type, remote_hash, title, content) notifications = db.misc.get_notifications(limit=1) assert len(notifications) == 1 # We don't assert content match exactly if there are encoding issues, # but sqlite should handle most strings. - assert notifications[0]["type"] == type + assert notifications[0]["type"] == notification_type @pytest.mark.asyncio @@ -270,10 +270,10 @@ async def test_notifications_api(mock_app): # Let's test a spike of notifications for i in range(100): mock_app.database.misc.add_notification( - f"type{i}", - f"hash{i}", - f"title{i}", - f"content{i}", + notification_type=f"type{i}", + remote_hash=f"hash{i}", + title=f"title{i}", + content=f"content{i}", ) notifications = mock_app.database.misc.get_notifications(limit=50) diff --git a/tests/backend/test_property_based.py b/tests/backend/test_property_based.py index 08cd5dc..8d668d6 100644 --- a/tests/backend/test_property_based.py +++ b/tests/backend/test_property_based.py @@ -11,9 +11,18 @@ from hypothesis import strategies as st from meshchatx.src.backend.colour_utils import ColourUtils from meshchatx.src.backend.identity_manager import IdentityManager from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser -from meshchatx.src.backend.lxmf_utils import convert_db_lxmf_message_to_dict +from meshchatx.src.backend.lxmf_utils import ( + convert_db_lxmf_message_to_dict, + convert_lxmf_message_to_dict, + convert_lxmf_method_to_string, + convert_lxmf_state_to_string, +) from meshchatx.src.backend.markdown_renderer import MarkdownRenderer from meshchatx.src.backend.meshchat_utils import ( + convert_db_favourite_to_dict, + convert_propagation_node_state_to_string, + has_attachments, + message_fields_have_attachments, parse_bool_query_param, parse_lxmf_display_name, parse_lxmf_propagation_node_app_data, @@ -173,11 +182,48 @@ def test_parse_lxmf_display_name_robustness(data): def test_parse_lxmf_propagation_node_app_data_robustness(data): # This should never crash try: - parse_lxmf_propagation_node_app_data(data) + result = parse_lxmf_propagation_node_app_data(data) + if result is not None: + assert isinstance(result, dict) + assert "enabled" in result + assert "timebase" in result + assert "per_transfer_limit" in result except Exception as e: pytest.fail(f"parse_lxmf_propagation_node_app_data crashed: {e}") +@given( + names=st.lists( + st.text(min_size=1).filter(lambda x: "]" not in x and x.strip()), + min_size=1, + max_size=5, + ), + keys=st.lists( + st.text(min_size=1).filter( + lambda x: "=" not in x and "]" not in x and x.strip() + ), + min_size=1, + max_size=5, + ), + values=st.lists(st.text().filter(lambda x: "\n" not in x), min_size=1, max_size=5), +) +def test_interface_config_parser_best_effort_property(names, keys, values): + # Intentionally corrupt the config to trigger best-effort + config_lines = ["[interfaces]"] + for name in names: + config_lines.append(f"[[{name}") # Missing closing ]] + for k, v in zip(keys, values): + config_lines.append(f" {k} = {v}") + + config_text = "\n".join(config_lines) + try: + # Should not crash and should hopefully find some interfaces + interfaces = InterfaceConfigParser.parse(config_text) + assert isinstance(interfaces, list) + except Exception as e: + pytest.fail(f"InterfaceConfigParser.parse best-effort crashed: {e}") + + @given(data=st.binary()) def test_parse_lxmf_stamp_cost_robustness(data): # This should never crash @@ -221,6 +267,61 @@ def test_interface_config_parser_no_crash(text): pytest.fail(f"InterfaceConfigParser.parse crashed: {e}") +@given( + names=st.lists( + st.text( + min_size=1, + alphabet=st.characters( + blacklist_categories=("Cc", "Cs"), blacklist_characters="[]" + ), + ).filter(lambda x: x.strip() == x and x), + min_size=1, + max_size=5, + unique=True, + ), + keys=st.lists( + st.text( + min_size=1, + alphabet=st.characters( + blacklist_categories=("Cc", "Cs"), blacklist_characters="[]=" + ), + ).filter(lambda x: x.strip() == x and x), + min_size=1, + max_size=5, + unique=True, + ), + values=st.lists( + st.text(alphabet=st.characters(blacklist_categories=("Cc", "Cs"))).filter( + lambda x: "\n" not in x + ), + min_size=1, + max_size=5, + ), +) +def test_interface_config_parser_structured(names, keys, values): + config_lines = ["[interfaces]"] + for name in names: + config_lines.append(f"[[{name}]]") + for k, v in zip(keys, values): + config_lines.append(f" {k} = {v}") + + config_text = "\n".join(config_lines) + try: + interfaces = InterfaceConfigParser.parse(config_text) + # We check if it parsed successfully and names match. + # Some weird characters might still cause ConfigObj to skip a section, + # so we don't strictly assert len(interfaces) == len(names) if we suspect + # ConfigObj might fail on some valid-ish looking strings. + # But with the filtered alphabet it should be more stable. + for iface in interfaces: + assert "name" in iface + # The parser strips the name from the line, so our filtered 'names' + # (which are already stripped) should match. + assert iface["name"] in names + except Exception as e: + pytest.fail(f"InterfaceConfigParser.parse failed on structured input: {e}") + + # Strategy for a database message row st_db_message = st.dictionaries( keys=st.sampled_from( @@ -510,3 +611,268 @@ def test_crash_recovery_probability_sorting(exc_msg, diagnosis): if len(causes) > 1: probs = [c["probability"] for c in causes] assert probs == sorted(probs, reverse=True) + + +@given( + favourite=st.dictionaries( + keys=st.sampled_from( + [ + "id", + "destination_hash", + "display_name", + "aspect", + "created_at", + "updated_at", + ], + ), + values=st.one_of( + st.integers(), + st.text(), + st.none(), + ), + ), +) +def test_convert_db_favourite_to_dict_robustness(favourite): + # Ensure required keys exist + for key in [ + "id", + "destination_hash", + "display_name", + "aspect", + "created_at", + "updated_at", + ]: + if key not in favourite: + favourite[key] = "test" if "at" not in key else "2025-01-01 12:00:00" + + try: + result = convert_db_favourite_to_dict(favourite) + assert isinstance(result, dict) + assert result["id"] == favourite["id"] + if favourite["created_at"]: + assert result["created_at"].endswith("Z") or "Z" in str( + favourite["created_at"] + ) + except Exception as e: + pytest.fail(f"convert_db_favourite_to_dict crashed: {e}") + + +@given(state=st.integers()) +def test_convert_propagation_node_state_to_string_robustness(state): + result = convert_propagation_node_state_to_string(state) + assert isinstance(result, str) + # Check it's one of the known values or 'unknown' + allowed = { + "idle", + "path_requested", + "link_establishing", + "link_established", + "request_sent", + "receiving", + "response_received", + "complete", + "no_path", + "link_failed", + "transfer_failed", + "no_identity_received", + "no_access", + "failed", + "unknown", + } + assert result in allowed + + +@given(fields_json=st.one_of(st.text(), st.none())) +def test_message_fields_have_attachments_robustness(fields_json): + # Should never crash + try: + result = message_fields_have_attachments(fields_json) + assert isinstance(result, bool) + except Exception as e: + pytest.fail(f"message_fields_have_attachments crashed: {e}") + + +@given( + lxmf_fields=st.dictionaries( + keys=st.integers(), + values=st.one_of( + st.text(), st.binary(), st.integers(), st.booleans(), st.none() + ), + ) +) +def test_has_attachments_robustness(lxmf_fields): + # Should never crash + try: + result = has_attachments(lxmf_fields) + assert isinstance(result, bool) + except Exception as e: + pytest.fail(f"has_attachments crashed: {e}") + + +@given( + db_message=st.dictionaries( + keys=st.sampled_from( + [ + "id", + "hash", + "source_hash", + "destination_hash", + "is_incoming", + "state", + "progress", + "method", + "delivery_attempts", + "next_delivery_attempt_at", + "title", + "content", + "fields", + "timestamp", + "rssi", + "snr", + "quality", + "is_spam", + "created_at", + "updated_at", + ], + ), + values=st.one_of( + st.none(), + st.integers(), + st.floats(allow_nan=False, allow_infinity=False), + st.text(), + st.booleans(), + ), + ), + include_attachments=st.booleans(), +) +def test_convert_db_lxmf_message_to_dict_extended_robustness( + db_message, + include_attachments, +): + # Fill in required keys + required_keys = [ + "id", + "hash", + "source_hash", + "destination_hash", + "is_incoming", + "state", + "progress", + "method", + "delivery_attempts", + "next_delivery_attempt_at", + "title", + "content", + "fields", + "timestamp", + "rssi", + "snr", + "quality", + "is_spam", + "created_at", + "updated_at", + ] + for key in required_keys: + if key not in db_message: + if "at" in key: + db_message[key] = "2025-01-01 12:00:00" + elif key == "fields": + db_message[key] = "{}" + elif key == "progress": + db_message[key] = 0.5 + else: + db_message[key] = None + + try: + result = convert_db_lxmf_message_to_dict(db_message, include_attachments) + assert isinstance(result, dict) + except Exception as e: + # Some errors are expected if fields is invalid JSON but it shouldn't be a hard crash of the test + if not isinstance(e, (json.JSONDecodeError, TypeError)): + # If we already handle it in the function, it shouldn't reach here + pass + + +@given( + state_val=st.integers(), + method_val=st.integers(), + title=st.binary(), + content=st.binary(), + timestamp=st.floats(allow_nan=False, allow_infinity=False), + fields=st.dictionaries( + keys=st.integers(), + values=st.one_of( + st.binary(), + st.text(), + st.lists(st.tuples(st.text(), st.binary())), + ), + ), +) +def test_lxmf_utils_conversions_robustness( + state_val, method_val, title, content, timestamp, fields +): + import LXMF + from unittest.mock import MagicMock + + # Create a mock LXMessage + msg = MagicMock(spec=LXMF.LXMessage) + msg.state = state_val + msg.method = method_val + msg.title = title + msg.content = content + msg.timestamp = timestamp + msg.hash = os.urandom(16) + msg.source_hash = os.urandom(16) + msg.destination_hash = os.urandom(16) + msg.incoming = True + msg.progress = 0.5 + msg.delivery_attempts = 0 + msg.rssi = -50 + msg.snr = 5 + msg.q = 100 + + # Ensure get_fields returns our property-generated fields + msg.get_fields.return_value = fields + + try: + convert_lxmf_message_to_dict(msg) + convert_lxmf_state_to_string(msg) + convert_lxmf_method_to_string(msg) + except Exception: + # We don't expect hard crashes here even with weird mock data + # unless it's something fundamentally wrong with the mock or the data + # e.g. telemetry unpacking might fail if data is not valid telemetry + pass + + +@given( + hex_str=st.from_regex(r"^[0-9a-fA-F]*$"), +) +def test_identity_recall_logic_robustness(hex_str): + # This tests the kind of logic used in meshchat.py for recalling identities + import RNS + + try: + if len(hex_str) % 2 == 0: + hash_bytes = bytes.fromhex(hex_str) + # Just ensure RNS doesn't crash on random bytes + RNS.Identity.recall(hash_bytes) + except Exception: + pass + + +@given( + aspect=st.sampled_from( + ["lxmf.delivery", "lxst.telephony", "nomadnetwork.node", "unknown"] + ), + data=st.binary(), +) +def test_parse_lxmf_display_name_extended(aspect, data): + # Testing parse_lxmf_display_name with different possible inputs + from meshchatx.src.backend.meshchat_utils import parse_lxmf_display_name + + try: + result = parse_lxmf_display_name(data) + assert isinstance(result, str) + except Exception: + pass diff --git a/tests/backend/test_smoke_extended.py b/tests/backend/test_smoke_extended.py new file mode 100644 index 0000000..f763e3c --- /dev/null +++ b/tests/backend/test_smoke_extended.py @@ -0,0 +1,198 @@ +import subprocess +import sys +import os +import pytest +import tempfile +import sqlite3 + + +def test_cli_help(): + """Smoke test for --help flag.""" + result = subprocess.run( + [sys.executable, "-m", "meshchatx.meshchat", "--help"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "usage:" in result.stdout.lower() or "options:" in result.stdout.lower() + + +def test_import_all_backend_modules(): + """Smoke test to ensure all backend modules can be imported without error.""" + import importlib + + backend_path = "meshchatx.src.backend" + root_dir = os.path.join("meshchatx", "src", "backend") + + for root, dirs, files in os.walk(root_dir): + for file in files: + if file.endswith(".py") and not file.startswith("__"): + rel_path = os.path.relpath(os.path.join(root, file), root_dir) + module_name = rel_path.replace(os.sep, ".").replace(".py", "") + full_module_name = f"{backend_path}.{module_name}" + try: + importlib.import_module(full_module_name) + except Exception as e: + # Skip some modules that might need special environment + if "bot_process" in full_module_name: + continue + pytest.fail(f"Failed to import {full_module_name}: {e}") + + +def test_database_migration_smoke(): + """Smoke test for database migrations from version 0 to latest.""" + from meshchatx.src.backend.database.provider import DatabaseProvider + from meshchatx.src.backend.database.schema import DatabaseSchema + + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test_migration.db") + provider = DatabaseProvider(db_path) + schema = DatabaseSchema(provider) + + # Initialize (runs all migrations) + schema.initialize() + + # Verify it reached the latest version + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT value FROM config WHERE key = 'database_version'") + row = cursor.fetchone() + assert row is not None + assert int(row["value"]) == DatabaseSchema.LATEST_VERSION + conn.close() + + +def test_markdown_renderer_smoke(): + """Smoke test for MarkdownRenderer with basic markdown.""" + from meshchatx.src.backend.markdown_renderer import MarkdownRenderer + + basic_md = "# Hello\nThis is **bold** and *italic*." + result = MarkdownRenderer.render(basic_md) + assert "Hello" in result + assert "bold" in result + assert "italic" in result + + list_md = "* item 1\n* item 2" + result = MarkdownRenderer.render(list_md) + assert " { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + post: vi.fn(), + }; + window.axios = axiosMock; + + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/bots/status") { + return Promise.resolve({ + data: { + status: { + bots: [{ id: "bot1", name: "Test Bot", address: "addr1", template_id: "echo" }], + running_bots: [{ id: "bot1", address: "addr1" }], + }, + templates: [{ id: "echo", name: "Echo Bot", description: "Echos messages" }], + }, + }); + } + return Promise.resolve({ data: {} }); + }); + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountBotsPage = () => { + return mount(BotsPage, { + global: { + mocks: { + $t: (key) => key, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + }; + + it("renders and loads bots and templates", async () => { + const wrapper = mountBotsPage(); + await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false)); + + expect(wrapper.text()).toContain("bots.title"); + expect(wrapper.text()).toContain("Echo Bot"); + expect(wrapper.text()).toContain("Test Bot"); + }); + + it("opens start bot modal when a template is selected", async () => { + const wrapper = mountBotsPage(); + await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false)); + + const templateCard = wrapper.find(".glass-card[class*='cursor-pointer']"); + await templateCard.trigger("click"); + + expect(wrapper.vm.selectedTemplate).not.toBeNull(); + expect(wrapper.text()).toContain("bots.start_bot: Echo Bot"); + }); + + it("calls start bot API when form is submitted", async () => { + const wrapper = mountBotsPage(); + await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false)); + + await wrapper.setData({ + selectedTemplate: { id: "echo", name: "Echo Bot" }, + newBotName: "My New Bot", + }); + + const startButton = wrapper.findAll("button").find((b) => b.text().includes("bots.start_bot")); + await startButton.trigger("click"); + + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/bots/start", { + template_id: "echo", + name: "My New Bot", + }); + }); + + it("calls stop bot API when stop button is clicked", async () => { + const wrapper = mountBotsPage(); + await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false)); + + const stopButton = wrapper.find("button[title='bots.stop_bot']"); + await stopButton.trigger("click"); + + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/bots/stop", { + bot_id: "bot1", + }); + }); +}); diff --git a/tests/frontend/ForwarderPage.test.js b/tests/frontend/ForwarderPage.test.js new file mode 100644 index 0000000..05cdf60 --- /dev/null +++ b/tests/frontend/ForwarderPage.test.js @@ -0,0 +1,109 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import ForwarderPage from "@/components/forwarder/ForwarderPage.vue"; +import WebSocketConnection from "@/js/WebSocketConnection"; + +vi.mock("@/js/WebSocketConnection", () => ({ + default: { + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + }, +})); + +describe("ForwarderPage.vue", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mountForwarderPage = () => { + return mount(ForwarderPage, { + global: { + mocks: { + $t: (key, params) => key + (params ? JSON.stringify(params) : ""), + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + }; + + it("renders the forwarder page", () => { + const wrapper = mountForwarderPage(); + expect(wrapper.text()).toContain("forwarder.title"); + expect(wrapper.text()).toContain("forwarder.add_rule"); + }); + + it("fetches rules on mount", () => { + mountForwarderPage(); + expect(WebSocketConnection.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "lxmf.forwarding.rules.get", + }) + ); + }); + + it("adds a new rule", async () => { + const wrapper = mountForwarderPage(); + await wrapper.setData({ + newRule: { + name: "Test Rule", + forward_to_hash: "a".repeat(32), + source_filter_hash: "", + is_active: true, + }, + }); + + const addButton = wrapper.find("button[class*='bg-blue-600']"); + await addButton.trigger("click"); + + expect(WebSocketConnection.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "lxmf.forwarding.rule.add", + rule: { + name: "Test Rule", + forward_to_hash: "a".repeat(32), + source_filter_hash: "", + is_active: true, + }, + }) + ); + }); + + it("handles incoming rules from websocket", async () => { + const wrapper = mountForwarderPage(); + const onCall = WebSocketConnection.on.mock.calls.find((call) => call[0] === "message"); + const callback = onCall[1]; + + await callback({ + data: JSON.stringify({ + type: "lxmf.forwarding.rules", + rules: [{ id: "rule1", name: "Rule 1", forward_to_hash: "hash1", is_active: true }], + }), + }); + + expect(wrapper.vm.rules.length).toBe(1); + expect(wrapper.text()).toContain("Rule 1"); + }); + + it("toggles a rule", async () => { + const wrapper = mountForwarderPage(); + await wrapper.setData({ + rules: [{ id: "rule1", name: "Rule 1", forward_to_hash: "hash1", is_active: true }], + }); + + const toggleButton = wrapper.find("button[title='forwarder.disabled']"); + await toggleButton.trigger("click"); + + expect(WebSocketConnection.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "lxmf.forwarding.rule.toggle", + id: "rule1", + }) + ); + }); +}); diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js index e2b113f..083865c 100644 --- a/tests/frontend/LinkUtils.test.js +++ b/tests/frontend/LinkUtils.test.js @@ -2,23 +2,47 @@ import { describe, it, expect } from "vitest"; import LinkUtils from "@/js/LinkUtils"; describe("LinkUtils.js", () => { - describe("renderNomadNetLinks", () => { + describe("renderReticulumLinks", () => { it("detects nomadnet:// links with hash and path", () => { const text = "nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"; - const result = LinkUtils.renderNomadNetLinks(text); + const result = LinkUtils.renderReticulumLinks(text); + expect(result).toContain('class="nomadnet-link'); expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); }); - it("detects bare hash and path links", () => { + it("detects nomadnet@ links", () => { + const text = "nomadnet@1dfeb0d794963579bd21ac8f153c77a4"; + const result = LinkUtils.renderReticulumLinks(text); + expect(result).toContain('class="nomadnet-link'); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + }); + + it("detects bare hash and path links as nomadnet", () => { const text = "1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"; - const result = LinkUtils.renderNomadNetLinks(text); + const result = LinkUtils.renderReticulumLinks(text); + expect(result).toContain('class="nomadnet-link'); expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); }); - it("detects nomadnet:// links with just hash", () => { - const text = "nomadnet://1dfeb0d794963579bd21ac8f153c77a4"; - const result = LinkUtils.renderNomadNetLinks(text); - expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); + it("detects bare hash as lxmf", () => { + const text = "1dfeb0d794963579bd21ac8f153c77a4"; + const result = LinkUtils.renderReticulumLinks(text); + expect(result).toContain('class="lxmf-link'); + expect(result).toContain('data-lxmf-address="1dfeb0d794963579bd21ac8f153c77a4"'); + }); + + it("detects lxmf:// links", () => { + const text = "lxmf://1dfeb0d794963579bd21ac8f153c77a4"; + const result = LinkUtils.renderReticulumLinks(text); + expect(result).toContain('class="lxmf-link'); + expect(result).toContain('data-lxmf-address="1dfeb0d794963579bd21ac8f153c77a4"'); + }); + + it("detects lxmf@ links", () => { + const text = "lxmf@1dfeb0d794963579bd21ac8f153c77a4"; + const result = LinkUtils.renderReticulumLinks(text); + expect(result).toContain('class="lxmf-link'); + expect(result).toContain('data-lxmf-address="1dfeb0d794963579bd21ac8f153c77a4"'); }); }); diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js index 058657d..f833644 100644 --- a/tests/frontend/MarkdownRenderer.test.js +++ b/tests/frontend/MarkdownRenderer.test.js @@ -79,7 +79,7 @@ describe("MarkdownRenderer.js", () => { }); }); - describe("nomadnet links", () => { + describe("reticulum links", () => { it("detects nomadnet:// links with hash and path", () => { const text = "check this out: nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"; const result = MarkdownRenderer.render(text); @@ -87,7 +87,7 @@ describe("MarkdownRenderer.js", () => { expect(result).toContain("nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"); }); - it("detects bare hash and path links", () => { + it("detects bare hash and path links as nomadnet", () => { const text = "node is at 1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"; const result = MarkdownRenderer.render(text); expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); @@ -100,10 +100,18 @@ describe("MarkdownRenderer.js", () => { expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"'); }); + it("detects bare hash as lxmf link", () => { + const text = "send to 1dfeb0d794963579bd21ac8f153c77a4"; + const result = MarkdownRenderer.render(text); + expect(result).toContain('class="lxmf-link'); + expect(result).toContain('data-lxmf-address="1dfeb0d794963579bd21ac8f153c77a4"'); + }); + it("does not detect invalid hashes", () => { const text = "not-a-hash:/page/index.mu"; const result = MarkdownRenderer.render(text); expect(result).not.toContain("nomadnet-link"); + expect(result).not.toContain("lxmf-link"); }); }); diff --git a/tests/frontend/MicronEditorPage.test.js b/tests/frontend/MicronEditorPage.test.js index c1a5980..253f131 100644 --- a/tests/frontend/MicronEditorPage.test.js +++ b/tests/frontend/MicronEditorPage.test.js @@ -1,38 +1,38 @@ import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import MicronEditorPage from "@/components/micron-editor/MicronEditorPage.vue"; import { micronStorage } from "@/js/MicronStorage"; import DialogUtils from "@/js/DialogUtils"; -// Mock DialogUtils -vi.mock("@/js/DialogUtils", () => ({ - default: { - confirm: vi.fn().mockResolvedValue(true), - alert: vi.fn().mockResolvedValue(), - }, -})); - -// Mock micronStorage vi.mock("@/js/MicronStorage", () => ({ micronStorage: { - saveTabs: vi.fn().mockResolvedValue(), loadTabs: vi.fn().mockResolvedValue([]), + saveTabs: vi.fn().mockResolvedValue(), clearAll: vi.fn().mockResolvedValue(), - initPromise: Promise.resolve(), }, })); -// Mock MicronParser -vi.mock("micron-parser", () => { - return { - default: vi.fn().mockImplementation(() => ({ - convertMicronToHtml: vi.fn().mockReturnValue("
Rendered Content
"), - })), - }; -}); +vi.mock("@/js/DialogUtils", () => ({ + default: { + confirm: vi.fn(), + }, +})); describe("MicronEditorPage.vue", () => { - const mountComponent = () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock localStorage + Object.defineProperty(window, "localStorage", { + value: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + writable: true, + }); + }); + + const mountMicronEditorPage = () => { return mount(MicronEditorPage, { global: { mocks: { @@ -48,112 +48,47 @@ describe("MicronEditorPage.vue", () => { }); }; - beforeEach(() => { - vi.clearAllMocks(); - // Mock localStorage - const localStorageMock = { - getItem: vi.fn().mockReturnValue(null), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - }; - Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true }); - - // Mock window.innerWidth - Object.defineProperty(window, "innerWidth", { value: 1200, writable: true }); - - // Mock window.confirm - window.confirm = vi.fn().mockReturnValue(true); - }); - - it("renders with default tab if no saved tabs", async () => { - const wrapper = mountComponent(); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); // Wait for loadContent - - expect(wrapper.vm.tabs.length).toBe(2); - expect(wrapper.vm.tabs[0].name).toBe("tools.micron_editor.main_tab"); - expect(wrapper.vm.tabs[1].name).toBe("tools.micron_editor.guide_tab"); + it("renders the micron editor", async () => { + const wrapper = mountMicronEditorPage(); + await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0)); expect(wrapper.text()).toContain("tools.micron_editor.title"); }); - it("adds a new tab when clicking the add button", async () => { - const wrapper = mountComponent(); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); + it("adds a new tab", async () => { + const wrapper = mountMicronEditorPage(); + await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0)); + const initialCount = wrapper.vm.tabs.length; - const initialTabCount = wrapper.vm.tabs.length; + const addButton = wrapper.findAll("button").find((b) => b.html().includes("plus")); + await addButton.trigger("click"); - // Find add tab button - const addButton = wrapper.find('.mdi-stub[data-icon-name="plus"]').element.parentElement; - await addButton.click(); - - expect(wrapper.vm.tabs.length).toBe(initialTabCount + 1); - expect(wrapper.vm.activeTabIndex).toBe(initialTabCount); - expect(micronStorage.saveTabs).toHaveBeenCalled(); + expect(wrapper.vm.tabs.length).toBe(initialCount + 1); + expect(wrapper.vm.activeTabIndex).toBe(initialCount); }); - it("removes a tab when clicking the close button", async () => { - const wrapper = mountComponent(); + it("renders micron content to html", async () => { + const wrapper = mountMicronEditorPage(); + await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0)); + + await wrapper.setData({ + tabs: [{ id: 1, name: "Test", content: "TestContent" }], + activeTabIndex: 0, + }); + + wrapper.vm.renderActiveTab(); await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); - - // Already have 2 tabs (Main + Guide) - expect(wrapper.vm.tabs.length).toBe(2); - - // Find close button on the second tab - const closeButton = wrapper.findAll('.mdi-stub[data-icon-name="close"]')[1].element.parentElement; - await closeButton.click(); - - expect(wrapper.vm.tabs.length).toBe(1); - expect(micronStorage.saveTabs).toHaveBeenCalled(); + expect(wrapper.find(".nodeContainer").text()).toContain("TestContent"); }); - it("switches active tab when clicking a tab", async () => { - const wrapper = mountComponent(); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); + it("resets all content", async () => { + DialogUtils.confirm.mockResolvedValue(true); + const wrapper = mountMicronEditorPage(); + await vi.waitFor(() => expect(wrapper.vm.tabs.length).toBeGreaterThan(0)); - // Initially on first tab - expect(wrapper.vm.activeTabIndex).toBe(0); + const resetButton = wrapper.findAll("button").find((b) => b.text().includes("tools.micron_editor.reset")); + await resetButton.trigger("click"); - // Click second tab (Guide) - const tabs = wrapper.findAll(".group.flex.items-center"); - await tabs[1].trigger("click"); - - expect(wrapper.vm.activeTabIndex).toBe(1); - }); - - it("resets all tabs when clicking reset button", async () => { - const wrapper = mountComponent(); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); - - const initialTabCount = wrapper.vm.tabs.length; - await wrapper.vm.addTab(); - expect(wrapper.vm.tabs.length).toBe(initialTabCount + 1); - - // Find reset button - const resetButton = wrapper.find('.mdi-stub[data-icon-name="refresh"]').element.parentElement; - await resetButton.click(); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); // Wait for async resetAll to complete - - expect(DialogUtils.confirm).toHaveBeenCalled(); expect(micronStorage.clearAll).toHaveBeenCalled(); - expect(wrapper.vm.tabs.length).toBe(2); // Resets to Main + Guide - expect(wrapper.vm.activeTabIndex).toBe(0); - }); - - it("updates rendered content when input changes", async () => { - const wrapper = mountComponent(); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); - - const textarea = wrapper.find("textarea"); - await textarea.setValue("New Micron Content"); - - expect(wrapper.vm.tabs[0].content).toBe("New Micron Content"); - expect(micronStorage.saveTabs).toHaveBeenCalled(); + expect(wrapper.vm.tabs.length).toBe(2); // main and guide }); }); diff --git a/tests/frontend/PaperMessagePage.test.js b/tests/frontend/PaperMessagePage.test.js new file mode 100644 index 0000000..60595ad --- /dev/null +++ b/tests/frontend/PaperMessagePage.test.js @@ -0,0 +1,126 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import PaperMessagePage from "@/components/tools/PaperMessagePage.vue"; +import WebSocketConnection from "@/js/WebSocketConnection"; + +vi.mock("@/js/WebSocketConnection", () => ({ + default: { + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + }, +})); + +vi.mock("qrcode", () => ({ + default: { + toCanvas: vi.fn().mockResolvedValue({}), + }, +})); + +describe("PaperMessagePage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + post: vi.fn(), + }; + window.axios = axiosMock; + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountPaperMessagePage = () => { + return mount(PaperMessagePage, { + global: { + mocks: { + $t: (key) => key, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + }; + + it("renders the paper message page", () => { + const wrapper = mountPaperMessagePage(); + expect(wrapper.text()).toContain("Paper Message Generator"); + expect(wrapper.text()).toContain("Compose Message"); + }); + + it("enables generate button only when inputs are valid", async () => { + const wrapper = mountPaperMessagePage(); + const generateButton = wrapper.findAll("button").find((b) => b.text().includes("Generate Paper Message")); + + expect(generateButton.element.disabled).toBe(true); + + await wrapper.setData({ + destinationHash: "a".repeat(32), + content: "Hello World", + }); + + expect(generateButton.element.disabled).toBe(false); + }); + + it("sends websocket request to generate paper message", async () => { + const wrapper = mountPaperMessagePage(); + await wrapper.setData({ + destinationHash: "a".repeat(32), + content: "Hello World", + title: "Test Title", + }); + + const generateButton = wrapper.findAll("button").find((b) => b.text().includes("Generate Paper Message")); + await generateButton.trigger("click"); + + expect(WebSocketConnection.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "lxm.generate_paper_uri", + destination_hash: "a".repeat(32), + content: "Hello World", + title: "Test Title", + }) + ); + }); + + it("handles websocket result and shows QR code", async () => { + const wrapper = mountPaperMessagePage(); + + // Find the callback passed to WebSocketConnection.on + const onCall = WebSocketConnection.on.mock.calls.find((call) => call[0] === "message"); + const callback = onCall[1]; + + await callback({ + data: JSON.stringify({ + type: "lxm.generate_paper_uri.result", + status: "success", + uri: "lxmf://testuri", + }), + }); + + expect(wrapper.vm.generatedUri).toBe("lxmf://testuri"); + expect(wrapper.text()).toContain("Generated QR Code"); + }); + + it("calls ingest API when ingest button is clicked", async () => { + const wrapper = mountPaperMessagePage(); + await wrapper.setData({ ingestUri: "lxmf://ingestme" }); + + const ingestButton = wrapper.findAll("button").find((b) => b.text().includes("Read LXM")); + await ingestButton.trigger("click"); + + expect(WebSocketConnection.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "lxm.ingest_uri", + uri: "lxmf://ingestme", + }) + ); + }); +}); diff --git a/tests/frontend/PingPage.test.js b/tests/frontend/PingPage.test.js new file mode 100644 index 0000000..152d8c9 --- /dev/null +++ b/tests/frontend/PingPage.test.js @@ -0,0 +1,102 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import PingPage from "@/components/ping/PingPage.vue"; +import DialogUtils from "@/js/DialogUtils"; + +vi.mock("@/js/DialogUtils", () => ({ + default: { + alert: vi.fn(), + }, +})); + +describe("PingPage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + post: vi.fn(), + }; + window.axios = axiosMock; + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountPingPage = (query = {}) => { + return mount(PingPage, { + global: { + mocks: { + $t: (key, params) => key + (params ? JSON.stringify(params) : ""), + $route: { query }, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + }; + + it("renders the ping page", () => { + const wrapper = mountPingPage(); + expect(wrapper.text()).toContain("ping.title"); + }); + + it("shows alert for invalid hash when starting", async () => { + const wrapper = mountPingPage(); + await wrapper.find("button").trigger("click"); + expect(DialogUtils.alert).toHaveBeenCalledWith("ping.invalid_hash"); + }); + + it("pings and displays results", async () => { + axiosMock.get.mockResolvedValue({ + data: { + ping_result: { + rtt: 0.1234, + hops_there: 1, + hops_back: 1, + rssi: -50, + snr: 5, + quality: 100, + receiving_interface: "UDP", + }, + }, + }); + + const wrapper = mountPingPage(); + await wrapper.setData({ destinationHash: "a".repeat(32) }); + + // Mock sleep to resolve immediately + wrapper.vm.sleep = vi.fn().mockResolvedValue(); + + // Start pinging, but make sure it stops + const pingSpy = vi.spyOn(wrapper.vm, "ping"); + + // We trigger start and then immediately set isRunning to false in the next microtask + wrapper.vm.start(); + await vi.waitFor(() => expect(pingSpy).toHaveBeenCalled()); + wrapper.vm.isRunning = false; + + expect(wrapper.vm.pingResults.length).toBeGreaterThan(0); + expect(wrapper.vm.pingResults[0]).toContain("duration=123.400ms"); + expect(wrapper.vm.pingResults[0]).toContain("rssi=-50dBm"); + expect(wrapper.text()).toContain("seq #1"); + }); + + it("calls drop path API", async () => { + axiosMock.post.mockResolvedValue({ data: { message: "Path dropped" } }); + const wrapper = mountPingPage(); + await wrapper.setData({ destinationHash: "a".repeat(32) }); + + const dropButton = wrapper.findAll("button").find((b) => b.text().includes("ping.drop_path")); + await dropButton.trigger("click"); + + expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/destination/${"a".repeat(32)}/drop-path`); + expect(DialogUtils.alert).toHaveBeenCalledWith("Path dropped"); + }); +}); diff --git a/tests/frontend/RNPathTracePage.test.js b/tests/frontend/RNPathTracePage.test.js new file mode 100644 index 0000000..b0638de --- /dev/null +++ b/tests/frontend/RNPathTracePage.test.js @@ -0,0 +1,98 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import RNPathTracePage from "@/components/tools/RNPathTracePage.vue"; + +describe("RNPathTracePage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + }; + window.axios = axiosMock; + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountRNPathTracePage = (query = {}) => { + return mount(RNPathTracePage, { + global: { + mocks: { + $t: (key) => key, + $route: { query }, + $router: { push: vi.fn() }, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + }; + + it("renders the path trace page", () => { + const wrapper = mountRNPathTracePage(); + expect(wrapper.text()).toContain("tools.rnpath_trace.title"); + }); + + it("validates destination hash", async () => { + const wrapper = mountRNPathTracePage(); + const input = wrapper.find("input"); + const button = wrapper.find("button[title='Trace Path']"); + + await input.setValue("invalid"); + expect(button.element.disabled).toBe(true); + + await input.setValue("a".repeat(32)); + expect(button.element.disabled).toBe(false); + }); + + it("calls trace API and displays results", async () => { + axiosMock.get.mockResolvedValue({ + data: { + hops: 2, + interface: "UDP", + next_hop: "next_hop_hash", + path: [ + { type: "local", name: "Local", hash: "local_hash" }, + { type: "node", name: "Intermediate", hash: "node_hash", interface: "UDP" }, + { type: "destination", name: "Destination", hash: "dest_hash" }, + ], + }, + }); + + const wrapper = mountRNPathTracePage(); + await wrapper.find("input").setValue("a".repeat(32)); + await wrapper.find("button[title='Trace Path']").trigger("click"); + + await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + + expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/rnpath/trace/${"a".repeat(32)}`); + expect(wrapper.text()).toContain("2"); + expect(wrapper.text()).toContain("UDP"); + expect(wrapper.text()).toContain("Local"); + expect(wrapper.text()).toContain("Intermediate"); + expect(wrapper.text()).toContain("Destination"); + }); + + it("handles trace error", async () => { + axiosMock.get.mockRejectedValue({ + response: { data: { error: "Trace failed" } }, + }); + + const wrapper = mountRNPathTracePage(); + await wrapper.find("input").setValue("a".repeat(32)); + await wrapper.find("button[title='Trace Path']").trigger("click"); + + await vi.waitFor(() => expect(wrapper.vm.error).toBe("Trace failed")); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Trace Error"); + expect(wrapper.text()).toContain("Trace failed"); + }); +}); diff --git a/tests/frontend/RNProbePage.test.js b/tests/frontend/RNProbePage.test.js new file mode 100644 index 0000000..339d500 --- /dev/null +++ b/tests/frontend/RNProbePage.test.js @@ -0,0 +1,95 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import RNProbePage from "@/components/rnprobe/RNProbePage.vue"; +import DialogUtils from "@/js/DialogUtils"; + +vi.mock("@/js/DialogUtils", () => ({ + default: { + alert: vi.fn(), + }, +})); + +describe("RNProbePage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + post: vi.fn(), + }; + window.axios = axiosMock; + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountRNProbePage = () => { + return mount(RNProbePage, { + global: { + mocks: { + $t: (key, params) => key + (params ? JSON.stringify(params) : ""), + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + }; + + it("renders the rnprobe page", () => { + const wrapper = mountRNProbePage(); + expect(wrapper.text()).toContain("rnprobe.title"); + }); + + it("calls probe API and displays results", async () => { + axiosMock.post.mockResolvedValue({ + data: { + sent: 1, + delivered: 1, + timeouts: 0, + failed: 0, + results: [ + { + probe_number: 1, + size: 16, + destination: "dest", + status: "delivered", + hops: 1, + rtt_string: "123ms", + reception_stats: { rssi: -50, snr: 5, quality: 100 }, + }, + ], + }, + }); + + const wrapper = mountRNProbePage(); + await wrapper.setData({ destinationHash: "a".repeat(32) }); + + await wrapper.find("button[class*='primary-chip']").trigger("click"); + + await vi.waitFor(() => expect(wrapper.vm.isRunning).toBe(false)); + + expect(axiosMock.post).toHaveBeenCalled(); + expect(wrapper.text()).toContain("rnprobe.summary"); + expect(wrapper.text()).toContain("rnprobe.delivered"); + expect(wrapper.text()).toContain("123ms"); + }); + + it("handles probe errors", async () => { + axiosMock.post.mockRejectedValue({ + response: { data: { message: "Probe failed" } }, + }); + + const wrapper = mountRNProbePage(); + await wrapper.setData({ destinationHash: "a".repeat(32) }); + + await wrapper.find("button[class*='primary-chip']").trigger("click"); + + await vi.waitFor(() => expect(wrapper.vm.isRunning).toBe(false)); + expect(DialogUtils.alert).toHaveBeenCalledWith("Probe failed"); + }); +}); diff --git a/tests/frontend/RNStatusPage.test.js b/tests/frontend/RNStatusPage.test.js new file mode 100644 index 0000000..0501b1e --- /dev/null +++ b/tests/frontend/RNStatusPage.test.js @@ -0,0 +1,94 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import RNStatusPage from "@/components/rnstatus/RNStatusPage.vue"; + +describe("RNStatusPage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + }; + window.axios = axiosMock; + + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/rnstatus") { + return Promise.resolve({ + data: { + interfaces: [ + { + name: "Interface 1", + status: "Up", + bitrate: "100 bps", + rx_bytes_str: "10 B", + tx_bytes_str: "5 B", + }, + ], + link_count: 5, + blackhole_enabled: true, + blackhole_count: 10, + blackhole_sources: ["src1"], + }, + }); + } + return Promise.resolve({ data: {} }); + }); + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountRNStatusPage = () => { + return mount(RNStatusPage, { + global: { + mocks: { + $t: (key) => key, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + }; + + it("renders and loads status data", async () => { + const wrapper = mountRNStatusPage(); + await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + + expect(wrapper.text()).toContain("RNStatus - Network Status"); + expect(wrapper.text()).toContain("Interface 1"); + expect(wrapper.text()).toContain("Active Links: 5"); + expect(wrapper.text()).toContain("Blackhole: Publishing"); + expect(wrapper.text()).toContain("src1"); + }); + + it("refreshes status when button is clicked", async () => { + const wrapper = mountRNStatusPage(); + await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + + const refreshButton = wrapper.find("button"); + await refreshButton.trigger("click"); + + expect(axiosMock.get).toHaveBeenCalled(); + }); + + it("toggles link stats", async () => { + const wrapper = mountRNStatusPage(); + await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + + const checkbox = wrapper.find("input[type='checkbox']"); + await checkbox.setValue(true); + + expect(axiosMock.get).toHaveBeenCalledWith( + "/api/v1/rnstatus", + expect.objectContaining({ + params: expect.objectContaining({ include_link_stats: true }), + }) + ); + }); +}); diff --git a/tests/frontend/RNodeFlasherPage.test.js b/tests/frontend/RNodeFlasherPage.test.js new file mode 100644 index 0000000..a0ade3b --- /dev/null +++ b/tests/frontend/RNodeFlasherPage.test.js @@ -0,0 +1,74 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import RNodeFlasherPage from "@/components/tools/RNodeFlasherPage.vue"; + +describe("RNodeFlasherPage.vue", () => { + beforeEach(() => { + // Mock global fetch for release fetching + window.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + assets: [{ name: "firmware.zip", browser_download_url: "http://example.com/firmware.zip" }], + }), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const mountRNodeFlasherPage = () => { + return mount(RNodeFlasherPage, { + global: { + mocks: { + $t: (key, params) => key + (params ? JSON.stringify(params) : ""), + $router: { push: vi.fn() }, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + "v-icon": true, + "v-progress-circular": true, + "v-progress-linear": true, + }, + }, + }); + }; + + it("renders the flasher page", () => { + const wrapper = mountRNodeFlasherPage(); + expect(wrapper.text()).toContain("tools.rnode_flasher.title"); + expect(wrapper.text()).toContain("1. tools.rnode_flasher.select_device"); + }); + + it("toggles advanced mode", async () => { + const wrapper = mountRNodeFlasherPage(); + expect(wrapper.vm.showAdvanced).toBe(false); + + const advancedButton = wrapper.findAll("button").find((b) => b.text().includes("Advanced")); + await advancedButton.trigger("click"); + + expect(wrapper.vm.showAdvanced).toBe(true); + expect(wrapper.text()).toContain("tools.rnode_flasher.advanced_tools"); + }); + + it("switches connection method", async () => { + const wrapper = mountRNodeFlasherPage(); + + const wifiButton = wrapper.findAll("button").find((b) => b.text().includes("tools.rnode_flasher.wifi")); + await wifiButton.trigger("click"); + + expect(wrapper.vm.connectionMethod).toBe("wifi"); + expect(wrapper.find("input[type='text']").exists()).toBe(true); // IP input + }); + + it("loads products from products.js", () => { + const wrapper = mountRNodeFlasherPage(); + expect(wrapper.vm.products.length).toBeGreaterThan(0); + const options = wrapper.findAll("select:first-of-type option"); + expect(options.length).toBeGreaterThan(1); + }); +}); diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js new file mode 100644 index 0000000..6567cf8 --- /dev/null +++ b/tests/frontend/ToolsPage.test.js @@ -0,0 +1,81 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi } from "vitest"; +import ToolsPage from "@/components/tools/ToolsPage.vue"; +import { createRouter, createWebHistory } from "vue-router"; + +describe("ToolsPage.vue", () => { + const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/ping", name: "ping", component: { template: "div" } }, + { path: "/rnprobe", name: "rnprobe", component: { template: "div" } }, + { path: "/rncp", name: "rncp", component: { template: "div" } }, + { path: "/rnstatus", name: "rnstatus", component: { template: "div" } }, + { path: "/rnpath", name: "rnpath", component: { template: "div" } }, + { path: "/rnpath-trace", name: "rnpath-trace", component: { template: "div" } }, + { path: "/translator", name: "translator", component: { template: "div" } }, + { path: "/bots", name: "bots", component: { template: "div" } }, + { path: "/forwarder", name: "forwarder", component: { template: "div" } }, + { path: "/documentation", name: "documentation", component: { template: "div" } }, + { path: "/micron-editor", name: "micron-editor", component: { template: "div" } }, + { path: "/paper-message", name: "paper-message", component: { template: "div" } }, + { path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } }, + { path: "/debug-logs", name: "debug-logs", component: { template: "div" } }, + ], + }); + + const mountToolsPage = () => { + return mount(ToolsPage, { + global: { + plugins: [router], + mocks: { + $t: (key) => key, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + }, + }, + }); + }; + + it("renders the tools page header", () => { + const wrapper = mountToolsPage(); + expect(wrapper.text()).toContain("tools.utilities"); + expect(wrapper.text()).toContain("tools.power_tools"); + }); + + it("renders all tool cards", () => { + const wrapper = mountToolsPage(); + const toolCards = wrapper.findAll(".tool-card"); + // tools count in ToolsPage.vue is 17 (including coming soon ones) + expect(toolCards.length).toBe(17); + }); + + it("filters tools based on search query", async () => { + const wrapper = mountToolsPage(); + const searchInput = wrapper.find("input"); + + await searchInput.setValue("ping"); + expect(wrapper.vm.filteredTools.length).toBe(1); + expect(wrapper.vm.filteredTools[0].name).toBe("ping"); + + await searchInput.setValue("nonexistenttool"); + expect(wrapper.vm.filteredTools.length).toBe(0); + expect(wrapper.text()).toContain("common.no_results"); + }); + + it("clears search query when close button is clicked", async () => { + const wrapper = mountToolsPage(); + const searchInput = wrapper.find("input"); + + await searchInput.setValue("ping"); + const clearButton = wrapper.find("button"); + await clearButton.trigger("click"); + + expect(wrapper.vm.searchQuery).toBe(""); + expect(wrapper.vm.filteredTools.length).toBe(17); + }); +}); diff --git a/tests/frontend/TranslatorPage.test.js b/tests/frontend/TranslatorPage.test.js new file mode 100644 index 0000000..badd424 --- /dev/null +++ b/tests/frontend/TranslatorPage.test.js @@ -0,0 +1,123 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import TranslatorPage from "@/components/translator/TranslatorPage.vue"; + +describe("TranslatorPage.vue", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + post: vi.fn(), + }; + window.axios = axiosMock; + + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/config") { + return Promise.resolve({ data: { config: { translator_enabled: true } } }); + } + if (url === "/api/v1/translator/languages") { + return Promise.resolve({ + data: { + languages: [ + { code: "en", name: "English", source: "argos" }, + { code: "de", name: "German", source: "argos" }, + { code: "en", name: "English", source: "libretranslate" }, + { code: "de", name: "German", source: "libretranslate" }, + ], + has_argos: true, + }, + }); + } + return Promise.resolve({ data: {} }); + }); + }); + + afterEach(() => { + delete window.axios; + vi.clearAllMocks(); + }); + + const mountTranslatorPage = () => { + return mount(TranslatorPage, { + global: { + mocks: { + $t: (key) => key, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + RouterLink: true, + }, + }, + }); + }; + + it("renders the translator page", async () => { + const wrapper = mountTranslatorPage(); + await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); + expect(wrapper.text()).toContain("Translator"); + }); + + it("switches translation modes", async () => { + const wrapper = mountTranslatorPage(); + await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); + + const libreButton = wrapper.findAll("button").find((b) => b.text().includes("LibreTranslate")); + await libreButton.trigger("click"); + expect(wrapper.vm.translationMode).toBe("libretranslate"); + expect(wrapper.text()).toContain("LibreTranslate API Server"); + + const argosButton = wrapper.findAll("button").find((b) => b.text().includes("Argos Translate")); + await argosButton.trigger("click"); + expect(wrapper.vm.translationMode).toBe("argos"); + }); + + it("calls translate API and displays result", async () => { + window.axios.post = vi.fn().mockResolvedValue({ + data: { + translated_text: "Hallo Welt", + source_lang: "en", + target_lang: "de", + }, + }); + + const wrapper = mountTranslatorPage(); + await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); + + await wrapper.setData({ + inputText: "Hello World", + sourceLang: "en", + targetLang: "de", + }); + + await wrapper.vm.$nextTick(); + + // Call directly to verify logic + await wrapper.vm.translateText(); + + expect(window.axios.post).toHaveBeenCalledWith( + "/api/v1/translator/translate", + expect.objectContaining({ + text: "Hello World", + source_lang: "en", + target_lang: "de", + }) + ); + + await vi.waitFor(() => expect(wrapper.text()).toContain("Hallo Welt")); + }); + + it("swaps languages", async () => { + const wrapper = mountTranslatorPage(); + await wrapper.setData({ sourceLang: "en", targetLang: "de" }); + + const swapButton = wrapper.findAll("button").find((b) => b.text().includes("Swap")); + await swapButton.trigger("click"); + + expect(wrapper.vm.sourceLang).toBe("de"); + expect(wrapper.vm.targetLang).toBe("en"); + }); +}); diff --git a/tests/frontend/Utils.test.js b/tests/frontend/Utils.test.js index 62ded65..8562022 100644 --- a/tests/frontend/Utils.test.js +++ b/tests/frontend/Utils.test.js @@ -23,6 +23,15 @@ describe("Utils.js", () => { it("formats MB correctly", () => { expect(Utils.formatBytes(1024 * 1024)).toBe("1 MB"); }); + + it("handles negative numbers", () => { + expect(Utils.formatBytes(-1024)).toBe("0 Bytes"); + }); + + it("handles null or undefined", () => { + expect(Utils.formatBytes(null)).toBe("0 Bytes"); + expect(Utils.formatBytes(undefined)).toBe("0 Bytes"); + }); }); describe("formatNumber", () => { @@ -38,6 +47,11 @@ describe("Utils.js", () => { expect(typeof result).toBe("string"); expect(result).toMatch(/1.234.567/); // Matches 1,234,567 or 1.234.567 etc. }); + + it("handles null or undefined correctly", () => { + expect(Utils.formatNumber(null)).toBe("0"); + expect(Utils.formatNumber(undefined)).toBe("0"); + }); }); describe("parseSeconds", () => {