feat: 统一自动化任务调度配置并增强聊天流恢复
This commit is contained in:
@@ -273,4 +273,27 @@ void main() {
|
||||
|
||||
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
test('sendMessage fails when SSE closes before terminal event', () async {
|
||||
final startedLines = _buildSseEvent(
|
||||
id: '41',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(sseLines: <String>[...startedLines]),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
service.sendMessage('hello'),
|
||||
throwsA(
|
||||
isA<StateError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('SSE closed before terminal event'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import 'package:social_app/features/chat/presentation/bloc/agent_stage.dart';
|
||||
|
||||
void main() {
|
||||
group('agent stage mapping', () {
|
||||
test('maps protocol step router to routing stage label', () {
|
||||
final stage = stageFromStepName('router');
|
||||
|
||||
expect(stage, AgentStage.routing);
|
||||
expect(stageLabel(stage), '意图识别中');
|
||||
});
|
||||
|
||||
test('maps protocol step worker to execution stage label', () {
|
||||
final stage = stageFromStepName('worker');
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class _FakeAgUiService extends AgUiService {
|
||||
_FakeAgUiService() : super(apiClient: _NoopApiClient());
|
||||
|
||||
Completer<SendMessageResult>? pendingResult;
|
||||
Completer<HistorySnapshot>? pendingHistory;
|
||||
Object? nextError;
|
||||
|
||||
@override
|
||||
@@ -67,6 +68,21 @@ class _FakeAgUiService extends AgUiService {
|
||||
return const SendMessageResult(uploadedAttachments: []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
|
||||
final pending = pendingHistory;
|
||||
if (pending != null) {
|
||||
return pending.future;
|
||||
}
|
||||
return const HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: null,
|
||||
day: null,
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[],
|
||||
);
|
||||
}
|
||||
|
||||
void emitEvent(AgUiEvent event) {
|
||||
onEvent(event);
|
||||
}
|
||||
@@ -189,5 +205,132 @@ void main() {
|
||||
expect(toolItem.errorMessage, '本次运行已失败');
|
||||
expect(bloc.state.error, 'runtime execution failed');
|
||||
});
|
||||
|
||||
test('text event with ui schema is rendered into chat items', () {
|
||||
service.emitEvent(RunStartedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-1',
|
||||
answer: '这是测试回复',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '测试 UI 卡片'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
service.emitEvent(RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
final messages = bloc.state.items.whereType<TextMessageItem>().toList();
|
||||
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
|
||||
|
||||
expect(messages, hasLength(1));
|
||||
expect(messages.single.content, '这是测试回复');
|
||||
expect(uiCards, hasLength(1));
|
||||
expect(uiCards.single.uiSchema['root'], isA<Map<String, dynamic>>());
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.currentStage, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'history loading does not overwrite real-time text and ui events',
|
||||
() async {
|
||||
final historyCompleter = Completer<HistorySnapshot>();
|
||||
service.pendingHistory = historyCompleter;
|
||||
|
||||
final loadFuture = bloc.loadHistory();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
service.emitEvent(
|
||||
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
||||
);
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-live',
|
||||
answer: '实时回复',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '实时 UI 卡片'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
historyCompleter.complete(
|
||||
const HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: 'thread-1',
|
||||
day: '2026-03-24',
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[],
|
||||
),
|
||||
);
|
||||
|
||||
await loadFuture;
|
||||
|
||||
final texts = bloc.state.items.whereType<TextMessageItem>().toList();
|
||||
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
|
||||
expect(texts.map((item) => item.id), contains('assistant-live'));
|
||||
expect(uiCards.map((item) => item.id), contains('assistant-live-ui'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'abnormal SSE close recovers from history without raw bad-state error',
|
||||
() async {
|
||||
service.nextError = StateError(
|
||||
'SSE closed before terminal event for run',
|
||||
);
|
||||
service.pendingHistory = Completer<HistorySnapshot>()
|
||||
..complete(
|
||||
HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: 'thread-1',
|
||||
day: '2026-03-24',
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[
|
||||
HistoryMessage(
|
||||
id: 'assistant-history-1',
|
||||
seq: 2,
|
||||
role: 'assistant',
|
||||
content: '历史补偿回复',
|
||||
timestamp: DateTime(2026, 3, 24, 17, 0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await bloc.sendMessage('你是谁?');
|
||||
|
||||
expect(bloc.state.error, isNull);
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.currentStage, isNull);
|
||||
expect(
|
||||
bloc.state.items
|
||||
.whereType<TextMessageItem>()
|
||||
.map((item) => item.content)
|
||||
.toList(),
|
||||
contains('历史补偿回复'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,73 +2,29 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
|
||||
|
||||
void main() {
|
||||
group('MessageContextConfigModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'source': 'messages',
|
||||
'window_mode': 'week',
|
||||
'window_count': 5,
|
||||
};
|
||||
|
||||
final model = MessageContextConfigModel.fromJson(json);
|
||||
|
||||
expect(model.source, 'messages');
|
||||
expect(model.windowMode, 'week');
|
||||
expect(model.windowCount, 5);
|
||||
});
|
||||
|
||||
test('fromJson uses defaults for missing fields', () {
|
||||
final model = MessageContextConfigModel.fromJson(null);
|
||||
|
||||
expect(model.source, 'latest_chat');
|
||||
expect(model.windowMode, 'day');
|
||||
expect(model.windowCount, 2);
|
||||
});
|
||||
|
||||
test('toJson serializes correctly', () {
|
||||
final model = MessageContextConfigModel(
|
||||
source: 'messages',
|
||||
windowMode: 'week',
|
||||
windowCount: 5,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['source'], 'messages');
|
||||
expect(json['window_mode'], 'week');
|
||||
expect(json['window_count'], 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobConfigModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
test('fromJson parses schedule correctly', () {
|
||||
final json = {
|
||||
'input_template': 'Hello {{name}}',
|
||||
'enabled_tools': ['tool1', 'tool2'],
|
||||
'context': {
|
||||
'source': 'messages',
|
||||
'window_mode': 'week',
|
||||
'source': 'latest_chat',
|
||||
'window_mode': 'day',
|
||||
'window_count': 5,
|
||||
},
|
||||
'schedule': {
|
||||
'type': 'weekly',
|
||||
'run_at': {'hour': 9, 'minute': 30},
|
||||
'weekdays': [1, 3, 5],
|
||||
},
|
||||
};
|
||||
|
||||
final model = AutomationJobConfigModel.fromJson(json);
|
||||
|
||||
expect(model.inputTemplate, 'Hello {{name}}');
|
||||
expect(model.enabledTools, ['tool1', 'tool2']);
|
||||
expect(model.context.source, 'messages');
|
||||
expect(model.context.windowMode, 'week');
|
||||
expect(model.context.windowCount, 5);
|
||||
});
|
||||
|
||||
test('fromJson uses defaults for null input', () {
|
||||
final model = AutomationJobConfigModel.fromJson(null);
|
||||
|
||||
expect(model.inputTemplate, '');
|
||||
expect(model.enabledTools, []);
|
||||
expect(model.context.source, 'latest_chat');
|
||||
expect(model.context.windowMode, 'day');
|
||||
expect(model.context.windowCount, 2);
|
||||
expect(model.schedule.type, 'weekly');
|
||||
expect(model.schedule.runAt.hour, 9);
|
||||
expect(model.schedule.runAt.minute, 30);
|
||||
expect(model.schedule.weekdays, [1, 3, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,8 +35,6 @@ void main() {
|
||||
'owner_id': 'user-456',
|
||||
'bootstrap_key': 'key-789',
|
||||
'title': 'Daily Report',
|
||||
'schedule_type': 'DAILY',
|
||||
'run_at': '09:00:00',
|
||||
'timezone': 'America/New_York',
|
||||
'status': 'ACTIVE',
|
||||
'is_system': false,
|
||||
@@ -92,6 +46,10 @@ void main() {
|
||||
'window_mode': 'day',
|
||||
'window_count': 2,
|
||||
},
|
||||
'schedule': {
|
||||
'type': 'daily',
|
||||
'run_at': {'hour': 9, 'minute': 0},
|
||||
},
|
||||
},
|
||||
'next_run_at': '2024-01-15T09:00:00Z',
|
||||
'last_run_at': '2024-01-14T09:00:00Z',
|
||||
@@ -103,117 +61,19 @@ void main() {
|
||||
|
||||
expect(model.id, 'job-123');
|
||||
expect(model.ownerId, 'user-456');
|
||||
expect(model.bootstrapKey, 'key-789');
|
||||
expect(model.title, 'Daily Report');
|
||||
expect(model.scheduleType, 'DAILY');
|
||||
expect(model.runAt, '09:00:00');
|
||||
expect(model.config.schedule.type, 'daily');
|
||||
expect(model.config.schedule.runAt.hour, 9);
|
||||
expect(model.timezone, 'America/New_York');
|
||||
expect(model.status, 'ACTIVE');
|
||||
expect(model.isSystem, false);
|
||||
expect(model.config.inputTemplate, 'Hello');
|
||||
expect(model.config.enabledTools, ['tool1']);
|
||||
expect(model.config.context.windowCount, 2);
|
||||
expect(model.nextRunAt, DateTime.parse('2024-01-15T09:00:00Z'));
|
||||
expect(model.lastRunAt, DateTime.parse('2024-01-14T09:00:00Z'));
|
||||
expect(model.createdAt, DateTime.parse('2024-01-01T00:00:00Z'));
|
||||
expect(model.updatedAt, DateTime.parse('2024-01-14T12:00:00Z'));
|
||||
});
|
||||
|
||||
test('fromJson throws for missing required date fields', () {
|
||||
final json = <String, dynamic>{
|
||||
'id': 'job-123',
|
||||
'owner_id': 'user-456',
|
||||
'title': 'Test',
|
||||
'schedule_type': 'DAILY',
|
||||
'run_at': '09:00:00',
|
||||
'timezone': 'UTC',
|
||||
'status': 'ACTIVE',
|
||||
'is_system': false,
|
||||
'config': null,
|
||||
};
|
||||
|
||||
expect(
|
||||
() => AutomationJobModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobConfigPatchModel', () {
|
||||
test('toJson only includes non-null fields', () {
|
||||
final model = AutomationJobConfigPatchModel(
|
||||
inputTemplate: 'Updated template',
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json.containsKey('input_template'), true);
|
||||
expect(json.containsKey('enabled_tools'), false);
|
||||
expect(json.containsKey('context'), false);
|
||||
expect(json['input_template'], 'Updated template');
|
||||
});
|
||||
|
||||
test('toJson includes all fields when set', () {
|
||||
final model = AutomationJobConfigPatchModel(
|
||||
inputTemplate: 'Template',
|
||||
enabledTools: ['tool1', 'tool2'],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'messages',
|
||||
windowMode: 'week',
|
||||
windowCount: 3,
|
||||
),
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['input_template'], 'Template');
|
||||
expect(json['enabled_tools'], ['tool1', 'tool2']);
|
||||
expect(json['context'], {
|
||||
'source': 'messages',
|
||||
'window_mode': 'week',
|
||||
'window_count': 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobUpdateRequest', () {
|
||||
test('toJson only includes non-null fields', () {
|
||||
final request = AutomationJobUpdateRequest(
|
||||
title: 'Updated Title',
|
||||
status: 'INACTIVE',
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json.containsKey('title'), true);
|
||||
expect(json.containsKey('status'), true);
|
||||
expect(json.containsKey('schedule_type'), false);
|
||||
expect(json.containsKey('run_at'), false);
|
||||
expect(json['title'], 'Updated Title');
|
||||
expect(json['status'], 'INACTIVE');
|
||||
});
|
||||
|
||||
test('toJson includes patch config with only non-null fields', () {
|
||||
final request = AutomationJobUpdateRequest(
|
||||
config: AutomationJobConfigPatchModel(inputTemplate: 'New template'),
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json.containsKey('config'), true);
|
||||
final configJson = json['config'] as Map<String, dynamic>;
|
||||
expect(configJson.containsKey('input_template'), true);
|
||||
expect(configJson.containsKey('enabled_tools'), false);
|
||||
expect(configJson.containsKey('context'), false);
|
||||
expect(model.isDaily, isTrue);
|
||||
expect(model.isWeekly, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobCreateRequest', () {
|
||||
test('toJson serializes correctly', () {
|
||||
test('toJson serializes schedule under config', () {
|
||||
final request = AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '10:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
config: AutomationJobConfigModel(
|
||||
@@ -224,74 +84,47 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 10, minute: 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['title'], 'New Job');
|
||||
expect(json['schedule_type'], 'DAILY');
|
||||
expect(json['run_at'], '10:00:00');
|
||||
expect(json['timezone'], 'UTC');
|
||||
expect(json['status'], 'ACTIVE');
|
||||
expect(json['config'], {
|
||||
'input_template': 'Hello',
|
||||
'enabled_tools': ['tool1'],
|
||||
'context': {
|
||||
'source': 'latest_chat',
|
||||
'window_mode': 'day',
|
||||
'window_count': 2,
|
||||
},
|
||||
expect((json['config'] as Map<String, dynamic>)['schedule'], {
|
||||
'type': 'daily',
|
||||
'run_at': {'hour': 10, 'minute': 0},
|
||||
});
|
||||
expect(json.containsKey('run_at'), isFalse);
|
||||
expect(json.containsKey('schedule_type'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobUpdateRequest', () {
|
||||
test('toJson includes schedule patch in config', () {
|
||||
final request = AutomationJobUpdateRequest(
|
||||
config: AutomationJobConfigPatchModel(
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'weekly',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
weekdays: [2, 4],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
final configJson = json['config'] as Map<String, dynamic>;
|
||||
|
||||
expect(configJson['schedule'], {
|
||||
'type': 'weekly',
|
||||
'run_at': {'hour': 8, 'minute': 0},
|
||||
'weekdays': [2, 4],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobListResponse', () {
|
||||
test('fromJson parses items correctly', () {
|
||||
final json = {
|
||||
'items': [
|
||||
{
|
||||
'id': 'job-1',
|
||||
'owner_id': 'user-1',
|
||||
'title': 'Job 1',
|
||||
'schedule_type': 'DAILY',
|
||||
'run_at': '09:00:00',
|
||||
'timezone': 'UTC',
|
||||
'status': 'ACTIVE',
|
||||
'is_system': false,
|
||||
'config': null,
|
||||
'next_run_at': '2024-01-15T09:00:00Z',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'updated_at': '2024-01-14T12:00:00Z',
|
||||
},
|
||||
{
|
||||
'id': 'job-2',
|
||||
'owner_id': 'user-1',
|
||||
'title': 'Job 2',
|
||||
'schedule_type': 'HOURLY',
|
||||
'run_at': '00:00:00',
|
||||
'timezone': 'UTC',
|
||||
'status': 'INACTIVE',
|
||||
'is_system': false,
|
||||
'config': null,
|
||||
'next_run_at': '2024-01-15T10:00:00Z',
|
||||
'created_at': '2024-01-02T00:00:00Z',
|
||||
'updated_at': '2024-01-14T12:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final response = AutomationJobListResponse.fromJson(json);
|
||||
|
||||
expect(response.items.length, 2);
|
||||
expect(response.items[0].id, 'job-1');
|
||||
expect(response.items[1].id, 'job-2');
|
||||
});
|
||||
|
||||
test('fromJson returns empty list for null items', () {
|
||||
final response = AutomationJobListResponse.fromJson(null);
|
||||
|
||||
expect(response.items, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ void main() {
|
||||
id: '1',
|
||||
ownerId: 'owner1',
|
||||
title: 'Test Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
isSystem: false,
|
||||
@@ -31,6 +29,10 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
nextRunAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
|
||||
@@ -21,8 +21,6 @@ void main() {
|
||||
id: '1',
|
||||
ownerId: 'owner1',
|
||||
title: 'Test Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
isSystem: false,
|
||||
@@ -34,6 +32,10 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
nextRunAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
@@ -173,8 +175,6 @@ void main() {
|
||||
act: (c) => c.createJob(
|
||||
AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'daily',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
@@ -185,6 +185,10 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -210,8 +214,6 @@ void main() {
|
||||
act: (c) => c.createJob(
|
||||
AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'daily',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
@@ -222,6 +224,10 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user