nomadcode/apps/client/test/integrations/proto_socket_task_service_test.dart
toki 3b7c76742a feat: workspace task domain, proto socket task service implementation
- 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
2026-05-30 21:22:20 +09:00

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'),
),
);
});
});
}