diff --git a/meshchatx.rsm b/meshchatx.rsm index 1a89653c..761f36c8 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/backend/http/routes/lxmf.py b/meshchatx/src/backend/http/routes/lxmf.py index caa83c41..7698f637 100644 --- a/meshchatx/src/backend/http/routes/lxmf.py +++ b/meshchatx/src/backend/http/routes/lxmf.py @@ -204,8 +204,13 @@ def register_lxmf_routes(routes, app): # sync propagation node # sync propagation node - @routes.get("/api/v1/lxmf/propagation-node/sync") + @routes.post("/api/v1/lxmf/propagation-node/sync") async def propagation_node_sync(request): + from meshchatx.src.backend.demo_mode import demo_mode_block_response + + blocked = demo_mode_block_response(app) + if blocked is not None: + return blocked # ensure propagation node is configured before attempting to sync outbound_node = app.message_router.get_outbound_propagation_node() if outbound_node is None: @@ -234,8 +239,13 @@ def register_lxmf_routes(routes, app): # stop syncing propagation node # stop syncing propagation node - @routes.get("/api/v1/lxmf/propagation-node/stop-sync") + @routes.post("/api/v1/lxmf/propagation-node/stop-sync") async def propagation_node_stop_sync(request): + from meshchatx.src.backend.demo_mode import demo_mode_block_response + + blocked = demo_mode_block_response(app) + if blocked is not None: + return blocked app.stop_propagation_node_sync() return web.json_response( diff --git a/meshchatx/src/backend/persistent_log_handler.py b/meshchatx/src/backend/persistent_log_handler.py index 4949ef2f..4ac995f2 100644 --- a/meshchatx/src/backend/persistent_log_handler.py +++ b/meshchatx/src/backend/persistent_log_handler.py @@ -42,6 +42,13 @@ class PersistentLogHandler(logging.Handler): def set_database(self, database): with self.lock: + if ( + self.database is not None + and database is not None + and self.database is not database + and self.logs_buffer + ): + self._flush_to_db() self.database = database def emit(self, record): diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 25ede795..d5652213 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -2109,7 +2109,7 @@ export default { // continue to sync } } - await window.api.get("/api/v1/lxmf/propagation-node/sync"); + await window.api.post("/api/v1/lxmf/propagation-node/sync"); } catch (e) { this.userInitiatedPropagationSync = false; const errorMessage = @@ -2202,7 +2202,7 @@ export default { async stopSyncingPropagationNode() { const propagationSyncToastKey = "propagation-sync-status"; try { - await window.api.get("/api/v1/lxmf/propagation-node/stop-sync"); + await window.api.post("/api/v1/lxmf/propagation-node/stop-sync"); } catch { // do nothing on error } diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue index ea965c39..7f124fa8 100644 --- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue +++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue @@ -3138,15 +3138,21 @@ export default { this.discovery.discoverable = this.parseBool(config.discoverable); } if (config.discovery_name) this.discovery.discovery_name = config.discovery_name; - if (config.announce_interval) this.discovery.announce_interval = Number(config.announce_interval); + if (config.announce_interval != null && config.announce_interval !== "") { + this.discovery.announce_interval = Number(config.announce_interval); + } if (config.reachable_on) this.discovery.reachable_on = config.reachable_on; if (config.discovery_stamp_value) this.discovery.discovery_stamp_value = Number(config.discovery_stamp_value); if (config.discovery_encrypt !== undefined) this.discovery.discovery_encrypt = this.parseBool(config.discovery_encrypt); if (config.publish_ifac !== undefined) this.discovery.publish_ifac = this.parseBool(config.publish_ifac); - if (config.latitude) this.discovery.latitude = Number(config.latitude); - if (config.longitude) this.discovery.longitude = Number(config.longitude); + if (config.latitude != null && config.latitude !== "") { + this.discovery.latitude = Number(config.latitude); + } + if (config.longitude != null && config.longitude !== "") { + this.discovery.longitude = Number(config.longitude); + } if (config.height) this.discovery.height = Number(config.height); if (config.location_cmd) this.discovery.location_cmd = String(config.location_cmd); diff --git a/tests/backend/test_debug_logs.py b/tests/backend/test_debug_logs.py index 62bb5eb4..470d0000 100644 --- a/tests/backend/test_debug_logs.py +++ b/tests/backend/test_debug_logs.py @@ -115,3 +115,32 @@ def test_log_cleanup(handler, db): count = db.debug_logs.get_total_count() assert count <= 11 # 10 + the trigger log + + +def test_set_database_flushes_buffered_logs_to_previous_database(tmp_path): + db_a_file = tmp_path / "a.db" + db_b_file = tmp_path / "b.db" + db_a = Database(str(db_a_file)) + db_b = Database(str(db_b_file)) + db_a.initialize() + db_b.initialize() + + handler = PersistentLogHandler(database=db_a, flush_interval=3600) + logger = logging.getLogger("identity_a_logger") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + logger.info("identity A buffered log") + assert len(handler.logs_buffer) == 1 + + handler.set_database(db_b) + logger.info("identity B log") + handler._flush_to_db() + + logs_a = db_a.debug_logs.get_logs(limit=10) + logs_b = db_b.debug_logs.get_logs(limit=10) + assert any("identity A buffered log" in row["message"] for row in logs_a) + assert all("identity A buffered log" not in row["message"] for row in logs_b) + assert any("identity B log" in row["message"] for row in logs_b) + + logger.removeHandler(handler) diff --git a/tests/backend/test_demo_mode_http_oracle.py b/tests/backend/test_demo_mode_http_oracle.py index 262c1cf8..cf33f620 100644 --- a/tests/backend/test_demo_mode_http_oracle.py +++ b/tests/backend/test_demo_mode_http_oracle.py @@ -24,6 +24,8 @@ from tests.backend.demo_http_support import build_test_aio_app ("POST", "/api/v1/identities/create"), ("DELETE", "/api/v1/lxmf-messages/aa"), ("PATCH", "/api/v1/server/security"), + ("POST", "/api/v1/lxmf/propagation-node/sync"), + ("POST", "/api/v1/lxmf/propagation-node/stop-sync"), ], ) @pytest.mark.asyncio diff --git a/tests/backend/test_lxmf_propagation_sync_integration.py b/tests/backend/test_lxmf_propagation_sync_integration.py index 2695196a..2d3a45fd 100644 --- a/tests/backend/test_lxmf_propagation_sync_integration.py +++ b/tests/backend/test_lxmf_propagation_sync_integration.py @@ -135,7 +135,7 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete( if request_count[key] >= 2: known_paths.add(key) - sync_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/sync") + sync_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/sync", method="POST") status_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/status") with ( @@ -175,7 +175,7 @@ async def test_local_preferred_propagation_sync_completes_without_remote_lookup( local_hash = fake_router.propagation_destination.hash fake_router.set_outbound_propagation_node(local_hash) - sync_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/sync") + sync_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/sync", method="POST") status_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/status") with ( diff --git a/tests/backend/test_lxmf_sync.py b/tests/backend/test_lxmf_sync.py index 7b194b1c..70dcc405 100644 --- a/tests/backend/test_lxmf_sync.py +++ b/tests/backend/test_lxmf_sync.py @@ -94,7 +94,7 @@ async def test_lxmf_sync_endpoints(mock_app): # 2. Test sync initiation sync_handler = None for route in mock_app.get_routes(): - if route.path == "/api/v1/lxmf/propagation-node/sync" and route.method == "GET": + if route.path == "/api/v1/lxmf/propagation-node/sync" and route.method == "POST": sync_handler = route.handler break @@ -137,7 +137,7 @@ async def test_specific_node_hash_validation(mock_app): # Trigger sync sync_handler = None for route in mock_app.get_routes(): - if route.path == "/api/v1/lxmf/propagation-node/sync" and route.method == "GET": + if route.path == "/api/v1/lxmf/propagation-node/sync" and route.method == "POST": sync_handler = route.handler break diff --git a/tests/frontend/AddInterfaceDiscovery.test.js b/tests/frontend/AddInterfaceDiscovery.test.js index 2c0a01ab..9e986a4b 100644 --- a/tests/frontend/AddInterfaceDiscovery.test.js +++ b/tests/frontend/AddInterfaceDiscovery.test.js @@ -278,4 +278,23 @@ target_port = 4242`; }) ); }); + + it("imports announce_interval zero and equator coordinates from config", async () => { + const wrapper = mountPage(); + + wrapper.vm.applyConfig({ + name: "ZeroInterval", + type: "TCPClientInterface", + target_host: "node.example", + target_port: "4242", + discoverable: "yes", + announce_interval: 0, + latitude: 0, + longitude: 0, + }); + + expect(wrapper.vm.discovery.announce_interval).toBe(0); + expect(wrapper.vm.discovery.latitude).toBe(0); + expect(wrapper.vm.discovery.longitude).toBe(0); + }); }); diff --git a/tests/frontend/AppPropagationSync.test.js b/tests/frontend/AppPropagationSync.test.js index 61b8bc8d..18c4d2fe 100644 --- a/tests/frontend/AppPropagationSync.test.js +++ b/tests/frontend/AppPropagationSync.test.js @@ -106,11 +106,13 @@ describe("App propagation sync", () => { }); it("shows detailed success toast with stored, confirmations and hidden counts", async () => { - axiosMock.post.mockResolvedValue({ data: { message: "ok" } }); - axiosMock.get.mockImplementation((url) => { + axiosMock.post.mockImplementation((url) => { if (url === "/api/v1/lxmf/propagation-node/sync") { return Promise.resolve({ data: { message: "Sync is starting" } }); } + return Promise.resolve({ data: { message: "ok" } }); + }); + axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/lxmf/propagation-node/status") { return Promise.resolve({ data: { @@ -138,17 +140,20 @@ describe("App propagation sync", () => { expect(ToastUtils.success).toHaveBeenCalledWith( "Sync complete. 8 messages received. (3 stored, 2 confirmations, 3 hidden)" ); + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/lxmf/propagation-node/sync"); expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/destination/deadbeef/request-path"); expect(ToastUtils.error).not.toHaveBeenCalled(); }); it("polls status while syncing and updates live loading toast", async () => { - axiosMock.post.mockResolvedValue({ data: { message: "ok" } }); - let statusCalls = 0; - axiosMock.get.mockImplementation((url) => { + axiosMock.post.mockImplementation((url) => { if (url === "/api/v1/lxmf/propagation-node/sync") { return Promise.resolve({ data: { message: "Sync is starting" } }); } + return Promise.resolve({ data: { message: "ok" } }); + }); + let statusCalls = 0; + axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/lxmf/propagation-node/status") { statusCalls += 1; if (statusCalls < 3) { @@ -196,11 +201,13 @@ describe("App propagation sync", () => { }); it("uses translated status in error toast when sync ends in a failure state", async () => { - axiosMock.post.mockResolvedValue({ data: { message: "ok" } }); - axiosMock.get.mockImplementation((url) => { + axiosMock.post.mockImplementation((url) => { if (url === "/api/v1/lxmf/propagation-node/sync") { return Promise.resolve({ data: { message: "Sync is starting" } }); } + return Promise.resolve({ data: { message: "ok" } }); + }); + axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/lxmf/propagation-node/status") { return Promise.resolve({ data: { @@ -228,20 +235,22 @@ describe("App propagation sync", () => { }); it("still starts sync when request-path fails (stale CSRF / brief offline after background)", async () => { - // Oracle: request-path is best-effort priming. The /sync GET already + // Oracle: request-path is best-effort priming. The /sync POST already // requests a path server-side. A CSRF or network failure on the POST // must not abort the user-initiated sync (common after a backgrounded tab). - axiosMock.post.mockRejectedValue( - Object.assign(new Error("HTTP 403"), { - response: { status: 403, data: { error: "Invalid or missing CSRF token" } }, - }) - ); let syncCalled = false; - axiosMock.get.mockImplementation((url) => { + axiosMock.post.mockImplementation((url) => { if (url === "/api/v1/lxmf/propagation-node/sync") { syncCalled = true; return Promise.resolve({ data: { message: "Sync is starting" } }); } + return Promise.reject( + Object.assign(new Error("HTTP 403"), { + response: { status: 403, data: { error: "Invalid or missing CSRF token" } }, + }) + ); + }); + axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/lxmf/propagation-node/status") { return Promise.resolve({ data: {