- Add workspace_task.dart for workspace task domain model - Add proto_socket_task_service.dart for ProtoSocket integration - Add proto_socket_task_service_test.dart for service tests - Clean up deprecated cloud-G06 task service files
263 lines
8 KiB
Dart
263 lines
8 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_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 {}
|
|
}
|
|
|
|
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'),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|