원격 터미널 브리지 POC 전에 Client HTTP lifecycle과 Edge run result surface 계약을 고정해야 하므로 관련 구현, 테스트, 로드맵 상태를 함께 정리한다.
286 lines
8.3 KiB
Dart
286 lines
8.3 KiB
Dart
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<http.StreamedResponse> send(http.BaseRequest request) async {
|
|
requestCount += 1;
|
|
final response = http.Response(
|
|
json.encode({'edges': <Map<String, dynamic>>[]}),
|
|
200,
|
|
);
|
|
return http.StreamedResponse(
|
|
Stream<List<int>>.fromIterable([response.bodyBytes]),
|
|
response.statusCode,
|
|
headers: response.headers,
|
|
request: request,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void close() {
|
|
closeCount += 1;
|
|
super.close();
|
|
}
|
|
}
|
|
|
|
class ControllableStatusRepository implements ControlPlaneStatusRepository {
|
|
final fleetCompleter = Completer<FleetStatusResponseView>();
|
|
final statusCompleter = Completer<EdgeStatusResponseView>();
|
|
final eventsCompleter = Completer<List<EdgeNodeEventView>>();
|
|
|
|
int fleetCallCount = 0;
|
|
int statusCallCount = 0;
|
|
int eventsCallCount = 0;
|
|
|
|
@override
|
|
Future<FleetStatusResponseView> fetchFleetStatus() {
|
|
fleetCallCount += 1;
|
|
return fleetCompleter.future;
|
|
}
|
|
|
|
@override
|
|
Future<EdgeStatusResponseView> fetchEdgeStatus(String edgeId) {
|
|
statusCallCount += 1;
|
|
return statusCompleter.future;
|
|
}
|
|
|
|
@override
|
|
Future<List<EdgeNodeEventView>> fetchEdgeEvents(String edgeId) {
|
|
eventsCallCount += 1;
|
|
return eventsCompleter.future;
|
|
}
|
|
|
|
@override
|
|
Future<List<EdgeRegistryView>> fetchEdges() async => [];
|
|
|
|
@override
|
|
Future<EdgeOperationsResponseView> fetchEdgeOperations(String edgeId) async {
|
|
return EdgeOperationsResponseView(edgeId: edgeId, operations: []);
|
|
}
|
|
|
|
@override
|
|
Future<EdgeCommandResponseView> sendEdgeCommand(
|
|
String edgeId,
|
|
String operation, {
|
|
String? targetSelector,
|
|
Map<String, String>? 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);
|
|
});
|
|
}
|