nomadcode/apps/client/test/integrations/proto_socket_lifecycle_test.dart

435 lines
14 KiB
Dart

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<ProtoSocketEnvelope> controller =
StreamController<ProtoSocketEnvelope>.broadcast();
int sendCalls = 0;
int closeCalls = 0;
ProtoSocketEnvelope? response;
@override
Stream<ProtoSocketEnvelope> get events => controller.stream;
@override
Future<ProtoSocketEnvelope> sendRequest(
ProtoSocketEnvelope envelope, {
Duration timeout = const Duration(seconds: 30),
}) async {
sendCalls += 1;
return response ?? envelope;
}
@override
Future<void> close() async {
closeCalls += 1;
await controller.close();
}
}
class _FakeConnector implements ProtoSocketConnector {
int connectCalls = 0;
int disconnectCalls = 0;
Object? throwOnConnect;
_FakeTransport transport = _FakeTransport();
@override
Future<ProtoSocketTransport> connect(ProtoSocketEndpointConfig config) async {
connectCalls += 1;
final err = throwOnConnect;
if (err != null) throw err;
return transport;
}
@override
Future<void> disconnect() async {
disconnectCalls += 1;
}
}
class _FakePeer implements NomadProtoSocketPeer {
final StreamController<ProtoSocketEnvelope> controller =
StreamController<ProtoSocketEnvelope>.broadcast();
ProtoSocketEnvelope? lastRequest;
Duration? lastTimeout;
int closeCalls = 0;
@override
Stream<ProtoSocketEnvelope> get envelopeEvents => controller.stream;
@override
Future<ProtoSocketEnvelope> 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<void> 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 = <ProtoSocketConnectionState>[];
final sub = lifecycle.stateStream.listen(states.add);
expect(lifecycle.state, ProtoSocketConnectionState.disconnected);
await lifecycle.connect(config);
expect(lifecycle.state, ProtoSocketConnectionState.connected);
expect(lifecycle.transport, isNotNull);
expect(fake.connectCalls, equals(1));
await lifecycle.disconnect();
expect(lifecycle.state, ProtoSocketConnectionState.disconnected);
expect(lifecycle.transport, isNull);
expect(fake.disconnectCalls, equals(1));
await Future<void>.delayed(Duration.zero);
await sub.cancel();
await lifecycle.dispose();
expect(states, [
ProtoSocketConnectionState.connecting,
ProtoSocketConnectionState.connected,
ProtoSocketConnectionState.disconnected,
]);
});
test('records response diagnostics from transport requests', () async {
final fake = _FakeConnector();
final lifecycle = ProtoSocketLifecycle(connector: fake);
fake.transport.response = const ProtoSocketEnvelope(
id: 'resp-1',
correlationId: 'req-1',
type: 'response',
channel: 'task',
action: 'task.list',
meta: {
'connection_id': 'conn-response',
'timestamp': '2026-05-30T12:00:00Z',
},
payload: {'secret': 'not-for-diagnostics'},
);
await lifecycle.connect(config);
await lifecycle.transport!.sendRequest(
const ProtoSocketEnvelope(
id: 'req-1',
type: 'request',
channel: 'task',
action: 'task.list',
),
);
expect(lifecycle.diagnostics.state, ProtoSocketConnectionState.connected);
expect(lifecycle.diagnostics.connectionId, equals('conn-response'));
expect(
lifecycle.diagnostics.protocolVersion,
equals(protoSocketProtocolVersion),
);
expect(lifecycle.diagnostics.channel, equals('task'));
expect(lifecycle.diagnostics.action, equals('task.list'));
expect(lifecycle.diagnostics.errorCode, isNull);
expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:00:00Z'));
await lifecycle.dispose();
});
test('records event diagnostics with error codes', () async {
final fake = _FakeConnector();
final lifecycle = ProtoSocketLifecycle(connector: fake);
await lifecycle.connect(config);
fake.transport.controller.add(
const ProtoSocketEnvelope(
id: 'event-1',
type: 'event',
channel: 'task',
action: 'task.status.changed',
error: ProtoSocketEnvelopeError(
code: 'task.conflict',
message: 'payload detail is private',
retryable: false,
),
meta: {
'connection_id': 'conn-event',
'timestamp': '2026-05-30T12:01:00Z',
},
payload: {'token': 'do-not-display'},
),
);
await Future<void>.delayed(Duration.zero);
expect(lifecycle.diagnostics.connectionId, equals('conn-event'));
expect(lifecycle.diagnostics.channel, equals('task'));
expect(lifecycle.diagnostics.action, equals('task.status.changed'));
expect(lifecycle.diagnostics.errorCode, equals('task.conflict'));
expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:01:00Z'));
await lifecycle.dispose();
});
test('disconnect clears stale connection diagnostics', () async {
final fake = _FakeConnector();
final lifecycle = ProtoSocketLifecycle(connector: fake);
await lifecycle.connect(config);
fake.transport.controller.add(
const ProtoSocketEnvelope(
id: 'event-1',
type: 'event',
channel: 'task',
action: 'task.status.changed',
error: ProtoSocketEnvelopeError(
code: 'task.conflict',
message: 'payload detail is private',
retryable: false,
),
meta: {
'connection_id': 'conn-stale',
'timestamp': '2026-05-30T12:00:00Z',
},
),
);
await Future<void>.delayed(Duration.zero);
expect(lifecycle.diagnostics.connectionId, equals('conn-stale'));
await lifecycle.disconnect();
expect(
lifecycle.diagnostics.state,
ProtoSocketConnectionState.disconnected,
);
expect(lifecycle.diagnostics.connectionId, isNull);
expect(lifecycle.diagnostics.channel, isNull);
expect(lifecycle.diagnostics.action, isNull);
expect(lifecycle.diagnostics.errorCode, isNull);
expect(lifecycle.diagnostics.timestamp, isNull);
// Protocol version is a constant, not connection-scoped, so it survives.
expect(
lifecycle.diagnostics.protocolVersion,
equals(protoSocketProtocolVersion),
);
await lifecycle.dispose();
});
test('reconnect start clears prior connection diagnostics', () async {
final fake = _FakeConnector();
final lifecycle = ProtoSocketLifecycle(connector: fake);
await lifecycle.connect(config);
fake.transport.controller.add(
const ProtoSocketEnvelope(
id: 'event-old',
type: 'event',
channel: 'task',
action: 'task.status.changed',
meta: {
'connection_id': 'conn-old',
'timestamp': '2026-05-30T12:00:00Z',
},
),
);
await Future<void>.delayed(Duration.zero);
expect(lifecycle.diagnostics.connectionId, equals('conn-old'));
await lifecycle.disconnect();
// The connecting transition during reconnect must not carry the prior
// connection id, channel, action, or timestamp into the debug surface.
final emitted = <ProtoSocketDiagnostics>[];
final sub = lifecycle.diagnosticsStream.listen(emitted.add);
await lifecycle.connect(config);
await sub.cancel();
final connecting = emitted.firstWhere(
(d) => d.state == ProtoSocketConnectionState.connecting,
);
expect(connecting.connectionId, isNull);
expect(connecting.channel, isNull);
expect(connecting.action, isNull);
expect(connecting.errorCode, isNull);
expect(connecting.timestamp, isNull);
await lifecycle.dispose();
});
test('failed reconnect clears prior connection diagnostics', () async {
final fake = _FakeConnector();
final lifecycle = ProtoSocketLifecycle(connector: fake);
await lifecycle.connect(config);
fake.transport.controller.add(
const ProtoSocketEnvelope(
id: 'event-old',
type: 'event',
channel: 'task',
action: 'task.status.changed',
error: ProtoSocketEnvelopeError(
code: 'task.conflict',
message: 'payload detail is private',
retryable: false,
),
meta: {
'connection_id': 'conn-old',
'timestamp': '2026-05-30T12:02:00Z',
},
),
);
await Future<void>.delayed(Duration.zero);
expect(lifecycle.diagnostics.connectionId, equals('conn-old'));
expect(lifecycle.diagnostics.channel, equals('task'));
expect(lifecycle.diagnostics.action, equals('task.status.changed'));
expect(lifecycle.diagnostics.errorCode, equals('task.conflict'));
expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:02:00Z'));
await lifecycle.disconnect();
fake.throwOnConnect = StateError('reconnect failed');
await expectLater(lifecycle.connect(config), throwsA(isA<StateError>()));
expect(lifecycle.diagnostics.state, ProtoSocketConnectionState.failed);
expect(lifecycle.diagnostics.connectionId, isNull);
expect(lifecycle.diagnostics.channel, isNull);
expect(lifecycle.diagnostics.action, isNull);
expect(lifecycle.diagnostics.errorCode, isNull);
expect(lifecycle.diagnostics.timestamp, isNull);
await lifecycle.dispose();
});
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<StateError>()));
expect(lifecycle.state, ProtoSocketConnectionState.failed);
expect(lifecycle.lastError, isA<StateError>());
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<Struct>(),
);
expect(parsers[qualifiedName]!(struct.writeToBuffer()), isA<Struct>());
});
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));
});
}