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

366 lines
11 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:nomadcode_app/src/features/workspaces/domain/workspace_task.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_endpoint_config.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_lifecycle.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_task_service.dart';
/// Fake transport that records outgoing envelopes and replays queued responses.
/// No network sockets are opened.
class _FakeTransport implements ProtoSocketTransport {
final List<ProtoSocketEnvelope> sent = [];
final List<ProtoSocketEnvelope> _responses = [];
void enqueueResponse(ProtoSocketEnvelope response) {
_responses.add(response);
}
@override
Future<ProtoSocketEnvelope> sendRequest(
ProtoSocketEnvelope envelope, {
Duration timeout = const Duration(seconds: 30),
}) async {
sent.add(envelope);
if (_responses.isEmpty) {
throw StateError('no queued response for ${envelope.action}');
}
return _responses.removeAt(0);
}
@override
Stream<ProtoSocketEnvelope> get events => const Stream.empty();
@override
Future<void> close() async {}
}
/// Connector that hands the lifecycle a fixed transport so the task service
/// exercises the real diagnostics wrapper the lifecycle installs.
class _DiagnosticsConnector implements ProtoSocketConnector {
final _FakeTransport transport;
_DiagnosticsConnector(this.transport);
@override
Future<ProtoSocketTransport> connect(ProtoSocketEndpointConfig config) async =>
transport;
@override
Future<void> disconnect() async {}
}
const _diagnosticsConfig = ProtoSocketEndpointConfig(
host: 'core.example.com',
port: 443,
secure: true,
);
ProtoSocketEnvelope _response(
String action,
Map<String, Object?> payload,
) {
return ProtoSocketEnvelope(
id: 'resp-$action',
correlationId: 'req-$action',
type: 'response',
channel: 'task',
action: action,
payload: payload,
);
}
ProtoSocketEnvelope _errorResponse(
String action,
ProtoSocketEnvelopeError error,
) {
return ProtoSocketEnvelope(
id: 'err-$action',
correlationId: 'req-$action',
type: 'error',
channel: 'task',
action: action,
error: error,
);
}
void main() {
group('WorkspaceTask.fromMap', () {
test('parses a full task map', () {
final task = WorkspaceTask.fromMap({
'id': 'task-1',
'title': 'Implement task service',
'status': 'pending',
'source': 'manual',
'error': 'boom',
});
expect(task.id, equals('task-1'));
expect(task.title, equals('Implement task service'));
expect(task.status, equals('pending'));
expect(task.source, equals('manual'));
expect(task.error, equals('boom'));
});
test('falls back for a minimal task map', () {
final task = WorkspaceTask.fromMap(const {'id': 'task-2'});
expect(task.id, equals('task-2'));
expect(task.title, equals('Untitled task'));
expect(task.status, equals('unknown'));
expect(task.source, equals(''));
expect(task.error, isNull);
});
});
group('ProtoSocketTaskService', () {
test('listTasks sends {limit} and parses tasks', () async {
final transport = _FakeTransport()
..enqueueResponse(
_response('task.list', {
'tasks': [
{'id': 'task-1', 'title': 'A', 'status': 'pending', 'source': 'manual'},
{'id': 'task-2', 'title': 'B', 'status': 'queued', 'source': 'plane'},
],
}),
);
final service = ProtoSocketTaskService(transport);
final tasks = await service.listTasks(limit: 5);
expect(tasks, hasLength(2));
expect(tasks.first.id, equals('task-1'));
final request = transport.sent.single;
expect(request.type, equals('request'));
expect(request.channel, equals('task'));
expect(request.action, equals('task.list'));
expect(request.payload, equals({'limit': 5}));
});
test('listTasks returns empty list for malformed tasks payload', () async {
final transport = _FakeTransport()
..enqueueResponse(_response('task.list', {'tasks': 'not-a-list'}));
final service = ProtoSocketTaskService(transport);
expect(await service.listTasks(), isEmpty);
});
test('getTask sends {id} and parses task', () async {
final transport = _FakeTransport()
..enqueueResponse(
_response('task.get', {
'task': {
'id': 'task-9',
'title': 'Detail',
'status': 'running',
'source': 'manual',
},
}),
);
final service = ProtoSocketTaskService(transport);
final task = await service.getTask('task-9');
expect(task.id, equals('task-9'));
expect(task.status, equals('running'));
expect(transport.sent.single.action, equals('task.get'));
expect(transport.sent.single.payload, equals({'id': 'task-9'}));
});
test('enqueueTask returns the updated task from payload.task', () async {
final transport = _FakeTransport()
..enqueueResponse(
_response('task.enqueue', {
'id': 'task-3',
'status': 'queued',
'task': {
'id': 'task-3',
'title': 'Run me',
'status': 'queued',
'source': 'manual',
},
}),
);
final service = ProtoSocketTaskService(transport);
final task = await service.enqueueTask('task-3');
expect(task.id, equals('task-3'));
expect(task.status, equals('queued'));
expect(transport.sent.single.action, equals('task.enqueue'));
expect(transport.sent.single.payload, equals({'id': 'task-3'}));
});
test('createTask sends documented request shape and parses task', () async {
final transport = _FakeTransport()
..enqueueResponse(
_response('task.create', {
'id': 'task-7',
'status': 'pending',
'task': {
'id': 'task-7',
'title': 'New',
'status': 'pending',
'source': 'manual',
},
}),
);
final service = ProtoSocketTaskService(transport);
final task = await service.createTask(
title: 'New',
source: 'manual',
payload: {'k': 'v'},
);
expect(task.id, equals('task-7'));
final request = transport.sent.single;
expect(request.action, equals('task.create'));
expect(request.payload['title'], equals('New'));
expect(request.payload['source'], equals('manual'));
expect(request.payload['payload'], equals({'k': 'v'}));
});
test('error envelope maps to ProtoSocketTaskException with retryable', () async {
final transport = _FakeTransport()
..enqueueResponse(
_errorResponse(
'task.get',
const ProtoSocketEnvelopeError(
code: 'task.not_found',
message: 'task not found',
retryable: false,
),
),
);
final service = ProtoSocketTaskService(transport);
await expectLater(
service.getTask('missing'),
throwsA(
isA<ProtoSocketTaskException>()
.having((e) => e.code, 'code', 'task.not_found')
.having((e) => e.message, 'message', 'task not found')
.having((e) => e.retryable, 'retryable', isFalse),
),
);
});
test('retryable internal error preserves retryable flag', () async {
final transport = _FakeTransport()
..enqueueResponse(
_errorResponse(
'task.list',
const ProtoSocketEnvelopeError(
code: 'internal.error',
message: 'internal server error',
retryable: true,
),
),
);
final service = ProtoSocketTaskService(transport);
await expectLater(
service.listTasks(),
throwsA(
isA<ProtoSocketTaskException>()
.having((e) => e.retryable, 'retryable', isTrue),
),
);
});
test('getTask throws service exception for malformed success payload', () async {
final transport = _FakeTransport()
..enqueueResponse(_response('task.get', {'task': 'not-a-map'}));
final service = ProtoSocketTaskService(transport);
await expectLater(
service.getTask('task-x'),
throwsA(
isA<ProtoSocketTaskException>()
.having((e) => e.code, 'code', 'client.malformed_response'),
),
);
});
test('records lifecycle diagnostics on successful task response', () async {
final transport = _FakeTransport()
..enqueueResponse(
ProtoSocketEnvelope(
id: 'response-1',
type: 'response',
channel: 'task',
action: 'task.list',
payload: const {
'tasks': [
{
'id': 'task-1',
'title': 'A',
'status': 'running',
'source': 'core',
},
],
},
meta: const {
'connection_id': 'conn-task',
'timestamp': '2026-05-30T12:00:00Z',
},
),
);
final lifecycle = ProtoSocketLifecycle(
connector: _DiagnosticsConnector(transport),
);
await lifecycle.connect(_diagnosticsConfig);
final service = ProtoSocketTaskService(lifecycle.transport!);
final tasks = await service.listTasks();
expect(tasks, hasLength(1));
expect(lifecycle.diagnostics.connectionId, equals('conn-task'));
expect(lifecycle.diagnostics.channel, equals('task'));
expect(lifecycle.diagnostics.action, equals('task.list'));
expect(
lifecycle.diagnostics.protocolVersion,
equals(protoSocketProtocolVersion),
);
expect(lifecycle.diagnostics.errorCode, isNull);
expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:00:00Z'));
await lifecycle.dispose();
});
test('records error code while still throwing on error envelope', () async {
final transport = _FakeTransport()
..enqueueResponse(
const ProtoSocketEnvelope(
id: 'err-1',
type: 'response',
channel: 'task',
action: 'task.list',
error: ProtoSocketEnvelopeError(
code: 'task.unavailable',
message: 'tasks temporarily unavailable',
retryable: true,
),
meta: {'connection_id': 'conn-task-err'},
),
);
final lifecycle = ProtoSocketLifecycle(
connector: _DiagnosticsConnector(transport),
);
await lifecycle.connect(_diagnosticsConfig);
final service = ProtoSocketTaskService(lifecycle.transport!);
await expectLater(
service.listTasks(),
throwsA(isA<ProtoSocketTaskException>()),
);
// The diagnostics wrapper captures the error code from the envelope even
// though the service maps it to a thrown exception.
expect(lifecycle.diagnostics.errorCode, equals('task.unavailable'));
expect(lifecycle.diagnostics.connectionId, equals('conn-task-err'));
await lifecycle.dispose();
});
});
}