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 sent = []; final List _responses = []; void enqueueResponse(ProtoSocketEnvelope response) { _responses.add(response); } @override Future 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 get events => const Stream.empty(); @override Future close() async {} } ProtoSocketEnvelope _response( String action, Map 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() .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() .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() .having((e) => e.code, 'code', 'client.malformed_response'), ), ); }); }); }