import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_client.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_endpoint_config.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_lifecycle.dart'; import 'package:protobuf/well_known_types/google/protobuf/struct.pb.dart'; class _FakeTransport implements ProtoSocketTransport { final StreamController controller = StreamController.broadcast(); int sendCalls = 0; int closeCalls = 0; ProtoSocketEnvelope? response; @override Stream get events => controller.stream; @override Future sendRequest( ProtoSocketEnvelope envelope, { Duration timeout = const Duration(seconds: 30), }) async { sendCalls += 1; return response ?? envelope; } @override Future close() async { closeCalls += 1; await controller.close(); } } class _FakeConnector implements ProtoSocketConnector { int connectCalls = 0; int disconnectCalls = 0; Object? throwOnConnect; _FakeTransport transport = _FakeTransport(); @override Future connect(ProtoSocketEndpointConfig config) async { connectCalls += 1; final err = throwOnConnect; if (err != null) throw err; return transport; } @override Future disconnect() async { disconnectCalls += 1; } } class _FakePeer implements NomadProtoSocketPeer { final StreamController controller = StreamController.broadcast(); ProtoSocketEnvelope? lastRequest; Duration? lastTimeout; int closeCalls = 0; @override Stream get envelopeEvents => controller.stream; @override Future sendEnvelopeRequest( ProtoSocketEnvelope envelope, { Duration timeout = const Duration(seconds: 30), }) async { lastRequest = envelope; lastTimeout = timeout; return ProtoSocketEnvelope( id: envelope.id, correlationId: envelope.correlationId, type: 'response', channel: envelope.channel, action: envelope.action, payload: const {'ok': true}, ); } @override Future close() async { closeCalls += 1; await controller.close(); } } void main() { const config = ProtoSocketEndpointConfig( host: 'core.example.com', port: 443, secure: true, ); test('happy path emits connecting -> connected -> disconnected', () async { final fake = _FakeConnector(); final lifecycle = ProtoSocketLifecycle(connector: fake); final states = []; final sub = lifecycle.stateStream.listen(states.add); expect(lifecycle.state, ProtoSocketConnectionState.disconnected); await lifecycle.connect(config); expect(lifecycle.state, ProtoSocketConnectionState.connected); expect(lifecycle.transport, same(fake.transport)); expect(fake.connectCalls, equals(1)); await lifecycle.disconnect(); expect(lifecycle.state, ProtoSocketConnectionState.disconnected); expect(lifecycle.transport, isNull); expect(fake.disconnectCalls, equals(1)); await Future.delayed(Duration.zero); await sub.cancel(); await lifecycle.dispose(); expect(states, [ ProtoSocketConnectionState.connecting, ProtoSocketConnectionState.connected, ProtoSocketConnectionState.disconnected, ]); }); test('connect failure transitions to failed and records error', () async { final fake = _FakeConnector()..throwOnConnect = StateError('boom'); final lifecycle = ProtoSocketLifecycle(connector: fake); await expectLater(lifecycle.connect(config), throwsA(isA())); expect(lifecycle.state, ProtoSocketConnectionState.failed); expect(lifecycle.lastError, isA()); expect(lifecycle.transport, isNull); await lifecycle.dispose(); }); test('connect is a no-op when already connecting or connected', () async { final fake = _FakeConnector(); final lifecycle = ProtoSocketLifecycle(connector: fake); await lifecycle.connect(config); await lifecycle.connect(config); expect(fake.connectCalls, equals(1)); await lifecycle.dispose(); }); test('disconnect from disconnected state is a no-op', () async { final fake = _FakeConnector(); final lifecycle = ProtoSocketLifecycle(connector: fake); await lifecycle.disconnect(); expect(fake.disconnectCalls, equals(0)); expect(lifecycle.state, ProtoSocketConnectionState.disconnected); await lifecycle.dispose(); }); test( 'RealProtoSocketConnector uses injected factories without sockets', () async { final fakeSocket = Object(); final fakeTransport = _FakeTransport(); ProtoSocketEndpointConfig? seenConfig; Object? seenSocket; final connector = RealProtoSocketConnector( socketFactory: (config) async { seenConfig = config; return fakeSocket; }, transportFactory: (socket, config) { seenSocket = socket; expect(config, same(seenConfig)); return fakeTransport; }, ); final transport = await connector.connect(config); expect(seenConfig, same(config)); expect(seenSocket, same(fakeSocket)); expect(transport, same(fakeTransport)); await connector.disconnect(); expect(fakeTransport.closeCalls, equals(1)); }, ); test('Struct parser map supports short and qualified names', () { final parsers = protoSocketStructParserMap(); final qualifiedName = Struct.getDefault().info_.qualifiedMessageName; final struct = ProtoSocketEnvelope( id: 'msg-1', type: 'event', channel: 'task', action: 'task.status.changed', payload: const {'id': 'task-1'}, ).toStruct(); expect(parsers[(Struct).toString()], isNotNull); expect(parsers[qualifiedName], isNotNull); expect( parsers[(Struct).toString()]!(struct.writeToBuffer()), isA(), ); expect(parsers[qualifiedName]!(struct.writeToBuffer()), isA()); }); test('ProtoSocketClientTransport delegates requests and close', () async { final peer = _FakePeer(); final transport = ProtoSocketClientTransport(peer); const request = ProtoSocketEnvelope( id: 'req-1', type: 'request', channel: 'task', action: 'task.list', ); final response = await transport.sendRequest( request, timeout: const Duration(seconds: 5), ); expect(peer.lastRequest, same(request)); expect(peer.lastTimeout, equals(const Duration(seconds: 5))); expect(response.type, equals('response')); expect(response.payload, equals({'ok': true})); await transport.close(); expect(peer.closeCalls, equals(1)); }); }