nomadcode/apps/client/test/integrations/proto_socket_lifecycle_test.dart
toki 88674917de feat: proto socket infrastructure communication rail implementation
- Add proto socket client and envelope implementations
- Add proto socket lifecycle integration tests
- Add task service PLAN and CODE_REVIEW documents
- Add workspace integration PLAN and CODE_REVIEW documents
- Update bootstrap with proto socket integration
- Update pubspec dependencies and lock file
- Update Flutter core API candidates documentation
- Update workflow core milestone tracking
2026-05-30 21:12:37 +09:00

232 lines
6.9 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, 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<void>.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<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));
});
}