366 lines
11 KiB
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();
|
|
});
|
|
});
|
|
}
|