From 5071e1f512cca47d1a2e91d9f17f466016359e8a Mon Sep 17 00:00:00 2001
From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com>
Date: Sat, 22 Aug 2026 20:21:19 -0700
Subject: [PATCH] chore(dev): seedable CAPsMAN fixture so managed-wireless
paths can be tested (v0.23.8-beta)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two bugs shipped in the last three releases because CAPsMAN code was written and
"verified" against a fleet that contains no CAPsMAN. Neither was reachable by unit
tests, because both lived in SQL: a join that could never match, and a WHERE clause
that filtered out every managed radio before the analysis ran. Both were found by
the reporter, in production, after release.
The fix is not another test of the pure functions — those were already right. It is
to stop the local environment from being unrepresentative.
scripts/seed-capsman-fixture.sql builds a deployment shaped like a real one:
- the controller is a ROUTER with no radios of its own, so anything keyed on
device_type = 'wireless_ap' misses it
- CAP interfaces carry NULL frequency, SSID and band, because the controller owns
the configuration — the exact condition that removed them from Channel Usage
- the controller mirrors every CAP interface locally, so a CAP's radio MAC
legitimately appears on two devices — the condition behind the misattribution
- channels exist only as "//", never a plain number
- the radios are arranged to produce one partial overlap and one co-channel pair,
so interference classification has something real to get wrong
Running it against v0.23.7 confirms the fix shipped blind two commits ago actually
works: six managed radios now group under the controller, the controller appears in
the location selector, the standalone group holds only the genuinely standalone AP,
5680/ax/eCee/D resolves to 80 MHz, client counts come from the controller where the
CAP rows read zero, and the controller's mirror interfaces do not double-count.
Documented in docs/architecture.md, including the point that when a report arrives
with device output attached, that output is the most valuable thing in it and
belongs in the fixture.
Co-Authored-By: Claude Opus 5
---
README.md | 2 +-
backend/package.json | 2 +-
docs/architecture.md | 37 +++++++++
frontend/package.json | 2 +-
frontend/src/version.ts | 2 +-
scripts/seed-capsman-fixture-clean.sql | 5 ++
scripts/seed-capsman-fixture.sql | 109 +++++++++++++++++++++++++
7 files changed, 155 insertions(+), 4 deletions(-)
create mode 100644 scripts/seed-capsman-fixture-clean.sql
create mode 100644 scripts/seed-capsman-fixture.sql
diff --git a/README.md b/README.md
index e243fe6..6162353 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
diff --git a/backend/package.json b/backend/package.json
index 39b78a9..b4f9e9a 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.7-beta",
+ "version": "0.23.8-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/docs/architecture.md b/docs/architecture.md
index 8c33c2d..c1322b3 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -109,6 +109,43 @@ Statements are written to be safe to re-run — `CREATE TABLE IF NOT EXISTS`,
`ADD COLUMN IF NOT EXISTS`, and guarded constraint swaps — so upgrading is just starting
the new image.
+## Developing against hardware you do not have
+
+Two bugs shipped in v0.23.5 and v0.23.6 because CAPsMAN code was written and
+"verified" against a fleet containing no CAPsMAN. Neither was catchable by unit
+tests: both lived in SQL — a join that could never match, and a `WHERE` clause that
+filtered out every managed radio before the analysis ran.
+
+`scripts/seed-capsman-fixture.sql` reproduces the shape of a real deployment so
+those paths can be exercised locally against the actual endpoints:
+
+```bash
+docker compose exec -T postgres psql -U mikrotik -d mikrotik_manager \
+ -q -f - < scripts/seed-capsman-fixture.sql
+
+# ... work, hit /api/wireless/rf/channels, /api/wireless/capsman ...
+
+docker compose exec -T postgres psql -U mikrotik -d mikrotik_manager \
+ -q -f - < scripts/seed-capsman-fixture-clean.sql
+```
+
+The fixture is deliberately awkward in the ways real deployments are:
+
+- the controller is a **router** with no radios of its own, so anything keyed on
+ `device_type = 'wireless_ap'` misses it
+- CAP interfaces carry **NULL frequency, SSID and band**, because the controller owns
+ the configuration — code that requires a frequency silently drops them
+- the controller **mirrors every CAP interface locally**, so a CAP's radio MAC
+ legitimately appears on two devices
+- channels appear only in RouterOS's `//` form, never
+ as a plain number
+- the radios are arranged to produce one partial overlap and one co-channel pair, so
+ interference classification has something real to get wrong
+
+Everything it creates is prefixed `fixture-`. Add to it rather than working from
+assumptions whenever a report arrives with device output attached — that output is
+the most valuable thing in the issue.
+
## Before you push
`scripts/ci-preflight.sh` runs the same gates as CI — lint, type-check, build, tests and
diff --git a/frontend/package.json b/frontend/package.json
index b61276f..36eea49 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.7-beta",
+ "version": "0.23.8-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index f8c30ed..b43a9ea 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = 'v0.23.7 Beta';
+export const APP_VERSION = 'v0.23.8 Beta';
diff --git a/scripts/seed-capsman-fixture-clean.sql b/scripts/seed-capsman-fixture-clean.sql
new file mode 100644
index 0000000..2cf12b6
--- /dev/null
+++ b/scripts/seed-capsman-fixture-clean.sql
@@ -0,0 +1,5 @@
+-- Remove everything seed-capsman-fixture.sql created. Devices cascade to their
+-- wireless_interfaces and capsman_* rows.
+DELETE FROM devices WHERE name LIKE 'fixture-%';
+SELECT 'cleaned' AS status,
+ (SELECT count(*) FROM devices WHERE name LIKE 'fixture-%') AS remaining;
diff --git a/scripts/seed-capsman-fixture.sql b/scripts/seed-capsman-fixture.sql
new file mode 100644
index 0000000..8083a33
--- /dev/null
+++ b/scripts/seed-capsman-fixture.sql
@@ -0,0 +1,109 @@
+-- A representative CAPsMAN deployment for local development.
+--
+-- Two bugs shipped in v0.23.5 and v0.23.6 because CAPsMAN code was written and
+-- "verified" against a fleet that contains no CAPsMAN at all. Neither was catchable
+-- by unit tests: both lived in SQL — a join that could not match, and a WHERE clause
+-- that filtered out every managed radio before the analysis ran.
+--
+-- This reproduces the shape of a real deployment (reported on issue #94) so those
+-- paths can be exercised locally against the actual endpoints:
+--
+-- * a controller that is a ROUTER with no radios of its own
+-- * CAPs whose own wireless_interfaces rows carry NULL frequency and NULL ssid,
+-- because the controller owns the configuration
+-- * the controller's local mirror of each CAP interface, which makes a CAP's radio
+-- MAC legitimately appear on two devices
+-- * channels only in RouterOS's "//" form
+--
+-- Everything is prefixed `fixture-` and removed by seed-capsman-fixture-clean.sql.
+
+BEGIN;
+
+-- Controller: a router. Deliberately NOT device_type 'wireless_ap', because that
+-- misclassification is what hid controllers from the wireless section originally.
+INSERT INTO devices (name, ip_address, api_username, api_password_encrypted,
+ device_type, status, model, wifi_role, wifi_package)
+VALUES ('fixture-core-rt-001', '10.250.0.1', 'admin', 'x',
+ 'router', 'online', 'RB5009UPr+S+', 'controller', 'wifi')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO devices (name, ip_address, api_username, api_password_encrypted,
+ device_type, status, model, wifi_role, wifi_package)
+VALUES ('fixture-hall-ap-001', '10.250.0.18', 'admin', 'x', 'wireless_ap', 'online', 'cAP ax', 'cap', 'wifi'),
+ ('fixture-left-ap-001', '10.250.0.20', 'admin', 'x', 'wireless_ap', 'online', 'cAP ax', 'cap', 'wifi'),
+ ('fixture-right-ap-001', '10.250.0.22', 'admin', 'x', 'wireless_ap', 'online', 'cAP ax', 'cap', 'wifi')
+ON CONFLICT DO NOTHING;
+
+-- CAP-side interfaces: no ssid, no frequency, no band. This is what a CAP actually
+-- reports, and assuming otherwise is what removed them from Channel Usage.
+INSERT INTO wireless_interfaces (device_id, name, ssid, frequency, channel_width, band,
+ disabled, running, registered_clients, managed_by_capsman,
+ radio_mac, mac_address, config_json)
+SELECT d.id, v.name, NULL, NULL, NULL, NULL, FALSE, TRUE, 0, TRUE, v.mac, v.mac, '{}'::jsonb
+FROM (VALUES
+ ('fixture-hall-ap-001', 'wifi1', '04:F4:1C:A2:C4:59'),
+ ('fixture-hall-ap-001', 'wifi2', '04:F4:1C:A2:C4:5A'),
+ ('fixture-left-ap-001', 'wifi1', '04:F4:1C:A3:1E:7B'),
+ ('fixture-left-ap-001', 'wifi2', '04:F4:1C:A3:1E:7C'),
+ ('fixture-right-ap-001', 'wifi1', '04:F4:1C:A5:0C:0D'),
+ ('fixture-right-ap-001', 'wifi2', '04:F4:1C:A5:0C:0E')
+) AS v(dev, name, mac)
+JOIN devices d ON d.name = v.dev
+ON CONFLICT (device_id, name) DO NOTHING;
+
+-- The controller mirrors every CAP interface locally, so each CAP radio MAC appears
+-- on two devices. A single-valued MAC index picked whichever row the database
+-- returned first, which is how radios ended up attributed to the controller.
+INSERT INTO wireless_interfaces (device_id, name, ssid, frequency, channel_width,
+ disabled, running, registered_clients, managed_by_capsman,
+ radio_mac, mac_address, config_json)
+SELECT d.id, v.name, NULL, NULL, NULL, FALSE, TRUE, 0, FALSE, v.mac, v.mac, '{}'::jsonb
+FROM (VALUES
+ ('wifi-fixture-hall-2g', '04:F4:1C:A2:C4:5A'),
+ ('wifi-fixture-hall-5g', '04:F4:1C:A2:C4:59'),
+ ('wifi-fixture-left-2g', '04:F4:1C:A3:1E:7C'),
+ ('wifi-fixture-left-5g', '04:F4:1C:A3:1E:7B'),
+ ('wifi-fixture-right-2g', '04:F4:1C:A5:0C:0E'),
+ ('wifi-fixture-right-5g', '04:F4:1C:A5:0C:0D')
+) AS v(name, mac)
+CROSS JOIN devices d
+WHERE d.name = 'fixture-core-rt-001'
+ON CONFLICT (device_id, name) DO NOTHING;
+
+-- Controller's view of the radios it manages. Channels only in RouterOS form, and
+-- deliberately including a partial overlap (2412 vs 2422) plus a co-channel pair
+-- (5680 twice) so the interference analysis has something real to classify.
+INSERT INTO capsman_radios (controller_device_id, radio_mac, interface_name, local, hw_type,
+ current_channel, matched_device_id, state, registered_peers,
+ authorized_peers, tx_power, ssid, config_json)
+SELECT c.id, v.mac, v.iface, FALSE, 'QCA6018', v.chan, a.id, 'running', v.peers, v.peers, 20, v.ssid, '{}'::jsonb
+FROM (VALUES
+ ('04:F4:1C:A2:C4:5A', 'wifi-fixture-hall-2g', '2412/ax/Ce', 'fixture-hall-ap-001', 8, 'Sunflower'),
+ ('04:F4:1C:A2:C4:59', 'wifi-fixture-hall-5g', '5680/ax/eCee/D', 'fixture-hall-ap-001', 1, 'Sunflower'),
+ ('04:F4:1C:A3:1E:7C', 'wifi-fixture-left-2g', '2422/ax/Ce', 'fixture-left-ap-001', 5, 'Sunflower'),
+ ('04:F4:1C:A3:1E:7B', 'wifi-fixture-left-5g', '5680/ax/eCee/D', 'fixture-left-ap-001', 7, 'Sunflower'),
+ ('04:F4:1C:A5:0C:0E', 'wifi-fixture-right-2g', '2462/ax/Ce', 'fixture-right-ap-001', 3, 'Sunflower'),
+ ('04:F4:1C:A5:0C:0D', 'wifi-fixture-right-5g', '5500/ax/Ceeeeeee/D','fixture-right-ap-001', 20, 'Sunflower')
+) AS v(mac, iface, chan, cap, peers, ssid)
+JOIN devices c ON c.name = 'fixture-core-rt-001'
+JOIN devices a ON a.name = v.cap
+ON CONFLICT (controller_device_id, radio_mac) DO NOTHING;
+
+INSERT INTO capsman_configurations (controller_device_id, name, ssid, mode, band, security, authentication_types, config_json)
+SELECT c.id, v.name, v.ssid, 'ap', v.band, 'public_security', 'wpa2-psk,wpa3-psk', '{}'::jsonb
+FROM (VALUES ('public_conf', 'Sunflower', NULL), ('public_conf_2g', 'Sunflower', '2ghz-ax'))
+ AS v(name, ssid, band)
+JOIN devices c ON c.name = 'fixture-core-rt-001'
+ON CONFLICT (controller_device_id, name) DO NOTHING;
+
+INSERT INTO capsman_provisioning (controller_device_id, ros_id, action, master_configuration, disabled, config_json)
+SELECT c.id, v.rid, 'create-dynamic-enabled', v.cfg, FALSE, '{}'::jsonb
+FROM (VALUES ('*f1', 'public_conf'), ('*f2', 'public_conf_2g')) AS v(rid, cfg)
+JOIN devices c ON c.name = 'fixture-core-rt-001'
+ON CONFLICT (controller_device_id, ros_id) DO NOTHING;
+
+COMMIT;
+
+SELECT 'seeded' AS status,
+ (SELECT count(*) FROM devices WHERE name LIKE 'fixture-%') AS devices,
+ (SELECT count(*) FROM capsman_radios) AS radios;