import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_test/flutter_test.dart'; import 'package:iop_client/control_plane_status_controller.dart'; import 'package:iop_client/control_plane_status_client.dart'; import 'widget_test.dart'; class TrackingHttpClient extends http.BaseClient { int requestCount = 0; int closeCount = 0; @override Future send(http.BaseRequest request) async { requestCount += 1; final response = http.Response( json.encode({'edges': >[]}), 200, ); return http.StreamedResponse( Stream>.fromIterable([response.bodyBytes]), response.statusCode, headers: response.headers, request: request, ); } @override void close() { closeCount += 1; super.close(); } } class ControllableStatusRepository implements ControlPlaneStatusRepository { final fleetCompleter = Completer(); final statusCompleter = Completer(); final eventsCompleter = Completer>(); int fleetCallCount = 0; int statusCallCount = 0; int eventsCallCount = 0; @override Future fetchFleetStatus() { fleetCallCount += 1; return fleetCompleter.future; } @override Future fetchEdgeStatus(String edgeId) { statusCallCount += 1; return statusCompleter.future; } @override Future> fetchEdgeEvents(String edgeId) { eventsCallCount += 1; return eventsCompleter.future; } @override Future> fetchEdges() async => []; @override Future fetchEdgeOperations(String edgeId) async { return EdgeOperationsResponseView(edgeId: edgeId, operations: []); } @override Future sendEdgeCommand( String edgeId, String operation, { String? targetSelector, Map? parameters, }) async { return EdgeCommandResponseView( requestId: 'req-1', commandId: 'cmd-1', edgeId: edgeId, status: 'success', summary: '', error: '', ); } } void main() { test('HTTP repository keeps injected client caller-owned', () async { final client = TrackingHttpClient(); final repository = HttpControlPlaneStatusRepository( baseUrl: 'http://control-plane.test', client: client, ); await repository.fetchEdges(); repository.close(); expect(client.requestCount, 1); expect(client.closeCount, 0); }); test( 'removes stale selected edge caches when fleet refresh drops edge', () async { final fakeStatusRepo = FakeControlPlaneStatusRepository(); final controller = ControlPlaneStatusController( statusRepository: fakeStatusRepo, ); // Initial fetch (edge-a and edge-b) await controller.fetchEdges(); expect(controller.edges.length, 2); expect(controller.selectedEdgeId, 'edge-a'); // Select edge-b and fetch details controller.selectEdge('edge-b'); await controller.fetchEdgeDetails('edge-b'); expect(controller.selectedEdgeId, 'edge-b'); expect(controller.edgeStatuses.containsKey('edge-b'), true); expect(controller.edgeEvents.containsKey('edge-b'), true); // Drop edge-b from mock repository fakeStatusRepo.mockEdges = [ EdgeRegistryView( edgeId: 'edge-a', edgeName: 'Edge Alpha', version: '1.2.0', capabilities: ['Serving', 'Automation'], protocol: 'iop-wire-v1', connected: true, lastSeen: DateTime.now(), ), ]; // Refresh fleet status await controller.fetchEdges(); // Verify selection falls back to edge-a and edge-b's cache is removed expect(controller.selectedEdgeId, 'edge-a'); expect(controller.edgeStatuses.containsKey('edge-b'), false); expect(controller.edgeEvents.containsKey('edge-b'), false); }, ); test('ignores in-flight fetch completions after dispose', () async { final fakeRepo = ControllableStatusRepository(); final controller = ControlPlaneStatusController(statusRepository: fakeRepo); // Start fetching edges final fetchFuture = controller.fetchEdges(); // Verify loading state is true expect(controller.loadingEdges, true); // Dispose controller controller.dispose(); // Complete the future fakeRepo.fleetCompleter.complete( FleetStatusResponseView( generatedAt: DateTime.now(), edges: [ FleetEdgeView( edgeId: 'edge-a', edgeName: 'Edge Alpha', version: '1.2.0', protocol: 'iop-wire-v1', connected: true, health: 'healthy', lastSeen: DateTime.now(), nodeCount: 1, capabilities: [], domainAgents: [], error: '', ), ], ), ); // Await the future completion await fetchFuture; // Verify state was NOT updated (should remain default/initial values) expect(controller.edges.isEmpty, true); expect( controller.loadingEdges, true, ); // Loading edges is still true because mutation didn't happen after dispose }); test('ignores in-flight fetch errors after dispose', () async { final fakeRepo = ControllableStatusRepository(); final controller = ControlPlaneStatusController(statusRepository: fakeRepo); final fetchFuture = controller.fetchEdges(); expect(controller.loadingEdges, true); controller.dispose(); fakeRepo.fleetCompleter.completeError(StateError('fleet failed')); await expectLater(fetchFuture, completes); expect(controller.edges.isEmpty, true); expect(controller.edgesError, isNull); expect(controller.loadingEdges, true); }); test('ignores status and events fetch completions after dispose', () async { final fakeRepo = ControllableStatusRepository(); final controller = ControlPlaneStatusController(statusRepository: fakeRepo); // Start fetching details final detailsFuture = controller.fetchEdgeDetails('edge-a'); // Verify loading status is set to true expect(controller.loadingStatuses['edge-a'], true); expect(controller.loadingEvents['edge-a'], true); // Dispose controller controller.dispose(); // Complete the status and events fakeRepo.statusCompleter.complete( EdgeStatusResponseView( requestId: 'req-1', edgeId: 'edge-a', edgeName: 'Edge Alpha', observedTimeUnixNano: 0, nodes: [], capabilities: [], domainAgents: [], metadata: {}, error: '', ), ); fakeRepo.eventsCompleter.complete([]); // Await the future completion await detailsFuture; // Verify state was NOT updated expect(controller.edgeStatuses.containsKey('edge-a'), false); expect(controller.edgeEvents.containsKey('edge-a'), false); }); test('ignores status and events fetch errors after dispose', () async { final fakeRepo = ControllableStatusRepository(); final controller = ControlPlaneStatusController(statusRepository: fakeRepo); final detailsFuture = controller.fetchEdgeDetails('edge-a'); expect(controller.loadingStatuses['edge-a'], true); expect(controller.loadingEvents['edge-a'], true); controller.dispose(); fakeRepo.statusCompleter.completeError(StateError('status failed')); fakeRepo.eventsCompleter.completeError(StateError('events failed')); await expectLater(detailsFuture, completes); expect(controller.edgeStatuses.containsKey('edge-a'), false); expect(controller.edgeEvents.containsKey('edge-a'), false); expect(controller.statusErrors['edge-a'], isNull); expect(controller.eventErrors['edge-a'], isNull); expect(controller.loadingStatuses['edge-a'], true); expect(controller.loadingEvents['edge-a'], true); }); test('ignores selectEdge after dispose', () { final fakeRepo = ControllableStatusRepository(); final controller = ControlPlaneStatusController(statusRepository: fakeRepo); controller.dispose(); controller.selectEdge('edge-a'); expect(controller.selectedEdgeId, isNull); expect(fakeRepo.statusCallCount, 0); expect(fakeRepo.eventsCallCount, 0); }); }