fix: implement 14 missing API endpoints, fix WebSocket connectivity, and replace 25 placeholder mobile tests

The web UI had persistent 404 errors on model, recording, and training
endpoints, and the sensing WebSocket never connected on Dashboard/Live
Demo tabs because sensingService.start() was only called lazily on
Sensing tab visit.

Server (main.rs):
- Add 14 fully-functional Axum handlers: model CRUD (7), recording
  lifecycle (4), training control (3)
- Scan data/models/ and data/recordings/ at startup
- Recording writes CSI frames to .jsonl via tokio background task
- Model load/unload lifecycle with state tracking

Web UI (app.js):
- Import and start sensingService early in initializeServices() so
  Dashboard and Live Demo tabs connect to /ws/sensing immediately

Mobile (ws.service.ts):
- Fix WebSocket URL builder to use same-origin port instead of
  hardcoded port 3001

Mobile (jest.config.js):
- Fix testPathIgnorePatterns that was ignoring the entire test directory

Mobile (25 test files):
- Replace all it.todo() placeholder tests with real implementations
  covering components, services, stores, hooks, screens, and utils

ADR-043 documents all changes.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-03-03 13:24:03 -05:00
parent 977da0f28e
commit 7485ebed57
34 changed files with 2975 additions and 87 deletions
@@ -1,5 +1,185 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import axios from 'axios';
jest.mock('axios', () => {
const mockAxiosInstance = {
request: jest.fn(),
};
const mockAxios = {
create: jest.fn(() => mockAxiosInstance),
isAxiosError: jest.fn(),
__mockInstance: mockAxiosInstance,
};
return {
__esModule: true,
default: mockAxios,
...mockAxios,
};
});
// Import after mocking so the mock takes effect
const { apiService } = require('@/services/api.service');
const mockAxios = axios as jest.Mocked<typeof axios> & { __mockInstance: { request: jest.Mock } };
describe('ApiService', () => {
const mockRequest = mockAxios.__mockInstance.request;
beforeEach(() => {
jest.clearAllMocks();
apiService.setBaseUrl('');
});
describe('setBaseUrl', () => {
it('stores the base URL', () => {
apiService.setBaseUrl('http://10.0.0.1:3000');
mockRequest.mockResolvedValueOnce({ data: { ok: true } });
apiService.get('/test');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: 'http://10.0.0.1:3000/test' }),
);
});
it('handles null by falling back to empty string', () => {
apiService.setBaseUrl(null as unknown as string);
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/api/status');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: '/api/status' }),
);
});
});
describe('buildUrl (via get)', () => {
it('concatenates baseUrl and path', () => {
apiService.setBaseUrl('http://example.com');
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/api/v1/status');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: 'http://example.com/api/v1/status' }),
);
});
it('removes trailing slash from baseUrl', () => {
apiService.setBaseUrl('http://example.com/');
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/test');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: 'http://example.com/test' }),
);
});
it('uses path as-is when baseUrl is empty', () => {
apiService.setBaseUrl('');
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/standalone');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: '/standalone' }),
);
});
it('uses the full URL path if path starts with http', () => {
apiService.setBaseUrl('http://base.com');
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('https://other.com/endpoint');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: 'https://other.com/endpoint' }),
);
});
});
describe('get', () => {
it('returns response data on success', async () => {
apiService.setBaseUrl('http://localhost:3000');
mockRequest.mockResolvedValueOnce({ data: { status: 'ok' } });
const result = await apiService.get('/api/v1/pose/status');
expect(result).toEqual({ status: 'ok' });
});
it('uses GET method', () => {
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/test');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET' }),
);
});
});
describe('post', () => {
it('sends body data', () => {
apiService.setBaseUrl('http://localhost:3000');
mockRequest.mockResolvedValueOnce({ data: { id: 1 } });
apiService.post('/api/events', { name: 'test' });
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
data: { name: 'test' },
}),
);
});
});
describe('error normalization', () => {
it('normalizes axios error with response data message', async () => {
const axiosError = {
message: 'Request failed with status code 400',
response: {
status: 400,
data: { message: 'Bad request body' },
},
code: 'ERR_BAD_REQUEST',
isAxiosError: true,
};
mockRequest.mockRejectedValue(axiosError);
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(true);
await expect(apiService.get('/test')).rejects.toEqual(
expect.objectContaining({
message: 'Bad request body',
status: 400,
code: 'ERR_BAD_REQUEST',
}),
);
});
it('normalizes generic Error', async () => {
mockRequest.mockRejectedValue(new Error('network timeout'));
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
await expect(apiService.get('/test')).rejects.toEqual(
expect.objectContaining({ message: 'network timeout' }),
);
});
it('normalizes unknown error', async () => {
mockRequest.mockRejectedValue('string error');
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
await expect(apiService.get('/test')).rejects.toEqual(
expect.objectContaining({ message: 'Unknown error' }),
);
});
});
describe('retry logic', () => {
it('retries up to 2 times on failure then throws', async () => {
const error = new Error('fail');
mockRequest.mockRejectedValue(error);
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
await expect(apiService.get('/flaky')).rejects.toEqual(
expect.objectContaining({ message: 'fail' }),
);
// 1 initial + 2 retries = 3 total calls
expect(mockRequest).toHaveBeenCalledTimes(3);
});
it('succeeds on second attempt without throwing', async () => {
mockRequest
.mockRejectedValueOnce(new Error('transient'))
.mockResolvedValueOnce({ data: { recovered: true } });
const result = await apiService.get('/flaky');
expect(result).toEqual({ recovered: true });
expect(mockRequest).toHaveBeenCalledTimes(2);
});
});
});
@@ -1,5 +1,96 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
// In the Jest environment (jsdom/node), Platform.OS defaults to a value that
// causes rssi.service.ts to load the web implementation. We test the web
// version which provides synthetic data.
jest.mock('react-native', () => {
const RN = jest.requireActual('react-native');
return {
...RN,
Platform: { ...RN.Platform, OS: 'web' },
};
});
describe('RssiService (web)', () => {
let rssiService: any;
beforeEach(() => {
jest.useFakeTimers();
jest.isolateModules(() => {
rssiService = require('@/services/rssi.service').rssiService;
});
});
afterEach(() => {
rssiService?.stopScanning();
jest.useRealTimers();
});
describe('subscribe / unsubscribe', () => {
it('subscribe returns an unsubscribe function', () => {
const listener = jest.fn();
const unsub = rssiService.subscribe(listener);
expect(typeof unsub).toBe('function');
unsub();
});
it('listener is not called without scanning', () => {
const listener = jest.fn();
rssiService.subscribe(listener);
jest.advanceTimersByTime(5000);
// Without startScanning, the listener should not be called
// (unless the service sends an initial broadcast, which web does on start)
expect(listener).not.toHaveBeenCalled();
});
});
describe('startScanning / stopScanning', () => {
it('startScanning delivers network data to subscribers', () => {
const listener = jest.fn();
rssiService.subscribe(listener);
rssiService.startScanning(1000);
// The web service immediately broadcasts once and sets up interval
expect(listener).toHaveBeenCalled();
const networks = listener.mock.calls[0][0];
expect(Array.isArray(networks)).toBe(true);
expect(networks.length).toBeGreaterThan(0);
expect(networks[0]).toHaveProperty('ssid');
expect(networks[0]).toHaveProperty('level');
});
it('stopScanning stops delivering data', () => {
const listener = jest.fn();
rssiService.subscribe(listener);
rssiService.startScanning(1000);
const callCount = listener.mock.calls.length;
rssiService.stopScanning();
jest.advanceTimersByTime(5000);
// No new calls after stopping
expect(listener.mock.calls.length).toBe(callCount);
});
it('unsubscribed listener does not receive scan results', () => {
const listener = jest.fn();
const unsub = rssiService.subscribe(listener);
unsub();
rssiService.startScanning(1000);
jest.advanceTimersByTime(3000);
expect(listener).not.toHaveBeenCalled();
});
});
describe('getLatestScan equivalent behavior', () => {
it('returns empty networks initially when no scan has run', () => {
// The web rssi service does not have a getLatestScan method,
// but we verify that without scanning no data is emitted.
const listener = jest.fn();
rssiService.subscribe(listener);
// No startScanning called
expect(listener).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,88 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import { generateSimulatedData } from '@/services/simulation.service';
describe('generateSimulatedData', () => {
it('returns a valid SensingFrame shape', () => {
const frame = generateSimulatedData();
expect(frame).toHaveProperty('type', 'sensing_update');
expect(frame).toHaveProperty('timestamp');
expect(frame).toHaveProperty('source', 'simulated');
expect(typeof frame.tick).toBe('number');
});
it('has a nodes array with at least one node', () => {
const frame = generateSimulatedData();
expect(Array.isArray(frame.nodes)).toBe(true);
expect(frame.nodes.length).toBeGreaterThanOrEqual(1);
const node = frame.nodes[0];
expect(typeof node.node_id).toBe('number');
expect(typeof node.rssi_dbm).toBe('number');
expect(Array.isArray(node.position)).toBe(true);
expect(node.position).toHaveLength(3);
});
it('has features object with expected numeric fields', () => {
const frame = generateSimulatedData();
const { features } = frame;
expect(typeof features.mean_rssi).toBe('number');
expect(typeof features.variance).toBe('number');
expect(typeof features.motion_band_power).toBe('number');
expect(typeof features.breathing_band_power).toBe('number');
expect(typeof features.spectral_entropy).toBe('number');
expect(typeof features.std).toBe('number');
expect(typeof features.dominant_freq_hz).toBe('number');
});
it('has classification with valid motion_level', () => {
const frame = generateSimulatedData();
const { classification } = frame;
expect(['absent', 'present_still', 'active']).toContain(classification.motion_level);
expect(typeof classification.presence).toBe('boolean');
expect(typeof classification.confidence).toBe('number');
expect(classification.confidence).toBeGreaterThanOrEqual(0);
expect(classification.confidence).toBeLessThanOrEqual(1);
});
it('has signal_field with correct grid_size', () => {
const frame = generateSimulatedData();
const { signal_field } = frame;
expect(signal_field.grid_size).toEqual([20, 1, 20]);
expect(Array.isArray(signal_field.values)).toBe(true);
expect(signal_field.values.length).toBe(20 * 20);
});
it('has signal_field values clamped between 0 and 1', () => {
const frame = generateSimulatedData();
for (const v of frame.signal_field.values) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
});
it('has vital_signs present', () => {
const frame = generateSimulatedData();
expect(frame.vital_signs).toBeDefined();
expect(typeof frame.vital_signs!.breathing_bpm).toBe('number');
expect(typeof frame.vital_signs!.hr_proxy_bpm).toBe('number');
expect(typeof frame.vital_signs!.confidence).toBe('number');
});
it('has estimated_persons field', () => {
const frame = generateSimulatedData();
expect(typeof frame.estimated_persons).toBe('number');
expect(frame.estimated_persons).toBeGreaterThanOrEqual(0);
});
it('produces different data for different timestamps', () => {
const frame1 = generateSimulatedData(1000);
const frame2 = generateSimulatedData(5000);
// The RSSI values should differ since the simulation is time-based
expect(frame1.features.mean_rssi).not.toBe(frame2.features.mean_rssi);
});
it('accepts a custom timeMs parameter', () => {
const t = 1700000000000;
const frame = generateSimulatedData(t);
expect(frame.timestamp).toBe(t);
});
});
@@ -1,5 +1,169 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
// We test the WsService class by importing a fresh instance.
// We need to mock the poseStore to prevent side effects.
jest.mock('@/stores/poseStore', () => ({
usePoseStore: {
getState: jest.fn(() => ({
setConnectionStatus: jest.fn(),
})),
},
}));
jest.mock('@/services/simulation.service', () => ({
generateSimulatedData: jest.fn(() => ({
type: 'sensing_update',
timestamp: Date.now(),
source: 'simulated',
nodes: [],
features: { mean_rssi: -45, variance: 1 },
classification: { motion_level: 'absent', presence: false, confidence: 0.5 },
signal_field: { grid_size: [20, 1, 20], values: [] },
})),
}));
// Create a fresh WsService for each test to avoid shared state
function createWsService() {
// Use jest.isolateModules to get a fresh module instance
let service: any;
jest.isolateModules(() => {
service = require('@/services/ws.service').wsService;
});
return service;
}
describe('WsService', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
describe('buildWsUrl', () => {
it('uses the same port as the HTTP URL, not a hardcoded port', () => {
// This is the critical bug-fix verification.
// buildWsUrl is private, so we test it indirectly via connect().
// We mock WebSocket to capture the URL it is called with.
const capturedUrls: string[] = [];
const OrigWebSocket = globalThis.WebSocket;
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
readyState = 0;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
onmessage: (() => void) | null = null;
close() {}
constructor(url: string) {
capturedUrls.push(url);
}
}
globalThis.WebSocket = MockWebSocket as any;
try {
const ws = createWsService();
// Test with port 3000
ws.connect('http://192.168.1.10:3000');
expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://192.168.1.10:3000/ws/sensing');
// Clean up, create another service
ws.disconnect();
const ws2 = createWsService();
// Test with port 8080
ws2.connect('http://myserver.local:8080');
expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://myserver.local:8080/ws/sensing');
ws2.disconnect();
// Test HTTPS -> WSS upgrade (port 443 is default for HTTPS so host drops it)
const ws3 = createWsService();
ws3.connect('https://secure.example.com:443');
expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing');
ws3.disconnect();
// Test WSS input
const ws4 = createWsService();
ws4.connect('wss://secure.example.com');
expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing');
ws4.disconnect();
// Verify port 3001 is NOT hardcoded anywhere
for (const url of capturedUrls) {
expect(url).not.toContain(':3001');
}
} finally {
globalThis.WebSocket = OrigWebSocket;
}
});
});
describe('connect with empty URL', () => {
it('falls back to simulation mode when URL is empty', () => {
const ws = createWsService();
ws.connect('');
expect(ws.getStatus()).toBe('simulated');
ws.disconnect();
});
});
describe('subscribe and unsubscribe', () => {
it('adds a listener and returns an unsubscribe function', () => {
const ws = createWsService();
const listener = jest.fn();
const unsub = ws.subscribe(listener);
expect(typeof unsub).toBe('function');
unsub();
ws.disconnect();
});
it('listener receives simulated frames', () => {
const ws = createWsService();
const listener = jest.fn();
ws.subscribe(listener);
ws.connect('');
// Advance timer to trigger simulation
jest.advanceTimersByTime(600);
expect(listener).toHaveBeenCalled();
const frame = listener.mock.calls[0][0];
expect(frame).toHaveProperty('type', 'sensing_update');
ws.disconnect();
});
it('unsubscribed listener does not receive frames', () => {
const ws = createWsService();
const listener = jest.fn();
const unsub = ws.subscribe(listener);
unsub();
ws.connect('');
jest.advanceTimersByTime(600);
expect(listener).not.toHaveBeenCalled();
ws.disconnect();
});
});
describe('disconnect', () => {
it('clears state and sets status to disconnected', () => {
const ws = createWsService();
ws.connect('');
expect(ws.getStatus()).toBe('simulated');
ws.disconnect();
expect(ws.getStatus()).toBe('disconnected');
});
});
describe('getStatus', () => {
it('returns disconnected initially', () => {
const ws = createWsService();
expect(ws.getStatus()).toBe('disconnected');
});
});
});