feat: 添加自动化任务(automation_jobs)功能模块

This commit is contained in:
qzl
2026-03-24 12:38:11 +08:00
parent f4b7eb7e09
commit 23359c2d01
43 changed files with 4266 additions and 1139 deletions
@@ -0,0 +1,297 @@
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', () {
final json = {
'input_template': 'Hello {{name}}',
'enabled_tools': ['tool1', 'tool2'],
'context': {
'source': 'messages',
'window_mode': 'week',
'window_count': 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);
});
});
group('AutomationJobModel', () {
test('fromJson parses all fields correctly', () {
final json = {
'id': 'job-123',
'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,
'config': {
'input_template': 'Hello',
'enabled_tools': ['tool1'],
'context': {
'source': 'latest_chat',
'window_mode': 'day',
'window_count': 2,
},
},
'next_run_at': '2024-01-15T09:00:00Z',
'last_run_at': '2024-01-14T09:00:00Z',
'created_at': '2024-01-01T00:00:00Z',
'updated_at': '2024-01-14T12:00:00Z',
};
final model = AutomationJobModel.fromJson(json);
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.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);
});
});
group('AutomationJobCreateRequest', () {
test('toJson serializes correctly', () {
final request = AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'DAILY',
runAt: '10:00:00',
timezone: 'UTC',
status: 'ACTIVE',
config: AutomationJobConfigModel(
inputTemplate: 'Hello',
enabledTools: ['tool1'],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
);
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,
},
});
});
});
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);
});
});
}
@@ -0,0 +1,165 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
import 'package:social_app/features/settings/presentation/cubits/automation_jobs_cubit.dart';
class MockAutomationJobsApi extends Mock implements AutomationJobsApi {}
class FakeAutomationJobUpdateRequest extends Fake
implements AutomationJobUpdateRequest {}
void main() {
late AutomationJobsCubit cubit;
late MockAutomationJobsApi mockApi;
final testJob = AutomationJobModel(
id: '1',
ownerId: 'owner1',
title: 'Test Job',
scheduleType: 'DAILY',
runAt: '08:00:00',
timezone: 'UTC',
status: 'ACTIVE',
isSystem: false,
config: AutomationJobConfigModel(
inputTemplate: '',
enabledTools: const [],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
nextRunAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
updatedAt: DateTime(2024, 1, 1),
);
setUpAll(() {
registerFallbackValue(FakeAutomationJobUpdateRequest());
});
setUp(() {
mockApi = MockAutomationJobsApi();
cubit = AutomationJobsCubit(mockApi);
});
tearDown(() {
cubit.close();
});
group('AutomationJobsCubit', () {
test('initial state is correct', () {
expect(cubit.state.jobs, isEmpty);
expect(cubit.state.isLoading, isFalse);
expect(cubit.state.error, isNull);
});
blocTest<AutomationJobsCubit, AutomationJobsState>(
'loadJobs success emits loading then jobs',
build: () {
when(() => mockApi.list()).thenAnswer((_) async => [testJob]);
return cubit;
},
act: (c) => c.loadJobs(),
expect: () => [
isA<AutomationJobsState>().having(
(s) => s.isLoading,
'isLoading',
true,
),
isA<AutomationJobsState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.jobs, 'jobs', [testJob]),
],
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'loadJobs failure emits loading then error',
build: () {
when(() => mockApi.list()).thenThrow(Exception('Network error'));
return cubit;
},
act: (c) => c.loadJobs(),
expect: () => [
isA<AutomationJobsState>().having(
(s) => s.isLoading,
'isLoading',
true,
),
isA<AutomationJobsState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.error, 'error', isNotNull),
],
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'deleteJob success calls loadJobs to refresh',
build: () {
when(() => mockApi.delete(any())).thenAnswer((_) async {});
when(() => mockApi.list()).thenAnswer((_) async => []);
return cubit;
},
act: (c) => c.deleteJob('1'),
verify: (_) {
verify(() => mockApi.delete('1')).called(1);
verify(() => mockApi.list()).called(1);
},
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'deleteJob failure emits error without refreshing',
build: () {
when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed'));
return cubit;
},
act: (c) => c.deleteJob('1'),
expect: () => [
isA<AutomationJobsState>().having((s) => s.error, 'error', isNotNull),
],
verify: (_) {
verify(() => mockApi.delete('1')).called(1);
verifyNever(() => mockApi.list());
},
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'updateJobStatus success replaces target job',
build: () {
when(
() => mockApi.update(any(), any()),
).thenAnswer((_) async => testJob.copyWith(status: 'disabled'));
return cubit;
},
seed: () => AutomationJobsState(jobs: [testJob]),
act: (c) => c.updateJobStatus(id: '1', enabled: false),
expect: () => [
isA<AutomationJobsState>().having(
(s) => s.jobs.first.status,
'updated status',
'disabled',
),
],
verify: (_) {
verify(() => mockApi.update('1', any())).called(1);
},
);
blocTest<AutomationJobsCubit, AutomationJobsState>(
'updateJobStatus failure emits error',
build: () {
when(
() => mockApi.update(any(), any()),
).thenThrow(Exception('Update failed'));
return cubit;
},
seed: () => AutomationJobsState(jobs: [testJob]),
act: (c) => c.updateJobStatus(id: '1', enabled: false),
expect: () => [
isA<AutomationJobsState>().having((s) => s.error, 'error', isNotNull),
],
);
});
}
@@ -0,0 +1,238 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
import 'package:social_app/features/settings/presentation/cubits/job_detail_cubit.dart';
class MockAutomationJobsApi extends Mock implements AutomationJobsApi {}
class FakeAutomationJobUpdateRequest extends Fake
implements AutomationJobUpdateRequest {}
class FakeAutomationJobCreateRequest extends Fake
implements AutomationJobCreateRequest {}
void main() {
late JobDetailCubit cubit;
late MockAutomationJobsApi mockApi;
final testJob = AutomationJobModel(
id: '1',
ownerId: 'owner1',
title: 'Test Job',
scheduleType: 'DAILY',
runAt: '08:00:00',
timezone: 'UTC',
status: 'ACTIVE',
isSystem: false,
config: AutomationJobConfigModel(
inputTemplate: '',
enabledTools: const [],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
nextRunAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
updatedAt: DateTime(2024, 1, 1),
);
setUpAll(() {
registerFallbackValue(FakeAutomationJobUpdateRequest());
registerFallbackValue(FakeAutomationJobCreateRequest());
});
setUp(() {
mockApi = MockAutomationJobsApi();
cubit = JobDetailCubit(mockApi);
});
tearDown(() {
cubit.close();
});
group('JobDetailCubit', () {
test('initial state is correct', () {
expect(cubit.state.job, isNull);
expect(cubit.state.isLoading, isFalse);
expect(cubit.state.isSaving, isFalse);
expect(cubit.state.error, isNull);
});
blocTest<JobDetailCubit, JobDetailState>(
'loadJob success emits loading then job',
build: () {
when(() => mockApi.get(any())).thenAnswer((_) async => testJob);
return cubit;
},
act: (c) => c.loadJob('1'),
expect: () => [
isA<JobDetailState>().having((s) => s.isLoading, 'isLoading', true),
isA<JobDetailState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.job, 'job', testJob),
],
);
blocTest<JobDetailCubit, JobDetailState>(
'loadJob failure emits loading then error',
build: () {
when(() => mockApi.get(any())).thenThrow(Exception('Network error'));
return cubit;
},
act: (c) => c.loadJob('1'),
expect: () => [
isA<JobDetailState>().having((s) => s.isLoading, 'isLoading', true),
isA<JobDetailState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.error, 'error', isNotNull),
],
);
blocTest<JobDetailCubit, JobDetailState>(
'updateJob success emits saving then job with saving false',
build: () {
when(
() => mockApi.update(any(), any()),
).thenAnswer((_) async => testJob);
return cubit;
},
act: (c) => c.updateJob('1', AutomationJobUpdateRequest()),
expect: () => [
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', true),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.job, 'job', testJob),
],
);
blocTest<JobDetailCubit, JobDetailState>(
'updateJob failure emits saving then error',
build: () {
when(
() => mockApi.update(any(), any()),
).thenThrow(Exception('Update failed'));
return cubit;
},
act: (c) => c.updateJob('1', AutomationJobUpdateRequest()),
expect: () => [
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', true),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.error, 'error', isNotNull),
],
);
blocTest<JobDetailCubit, JobDetailState>(
'deleteJob success emits saving then saving false',
build: () {
when(() => mockApi.delete(any())).thenAnswer((_) async {});
return cubit;
},
act: (c) => c.deleteJob('1'),
expect: () => [
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', true)
.having((s) => s.error, 'error', isNull),
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', false),
],
verify: (_) {
verify(() => mockApi.delete('1')).called(1);
},
);
blocTest<JobDetailCubit, JobDetailState>(
'deleteJob failure emits saving then error',
build: () {
when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed'));
return cubit;
},
act: (c) => c.deleteJob('1'),
expect: () => [
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', true)
.having((s) => s.error, 'error', isNull),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.error, 'error', isNotNull),
],
verify: (_) {
verify(() => mockApi.delete('1')).called(1);
},
);
blocTest<JobDetailCubit, JobDetailState>(
'createJob success emits saving then created job',
build: () {
when(() => mockApi.create(any())).thenAnswer((_) async => testJob);
return cubit;
},
act: (c) => c.createJob(
AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'daily',
runAt: '08:00:00',
timezone: 'Asia/Shanghai',
status: 'active',
config: AutomationJobConfigModel(
inputTemplate: 'hello',
enabledTools: const ['memory.write'],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
),
),
expect: () => [
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', true)
.having((s) => s.error, 'error', isNull),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.job, 'job', testJob),
],
verify: (_) {
verify(() => mockApi.create(any())).called(1);
},
);
blocTest<JobDetailCubit, JobDetailState>(
'createJob failure emits saving then error',
build: () {
when(() => mockApi.create(any())).thenThrow(Exception('Create failed'));
return cubit;
},
act: (c) => c.createJob(
AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'daily',
runAt: '08:00:00',
timezone: 'Asia/Shanghai',
status: 'active',
config: AutomationJobConfigModel(
inputTemplate: 'hello',
enabledTools: const ['memory.write'],
context: MessageContextConfigModel(
source: 'latest_chat',
windowMode: 'day',
windowCount: 2,
),
),
),
),
expect: () => [
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', true)
.having((s) => s.error, 'error', isNull),
isA<JobDetailState>()
.having((s) => s.isSaving, 'isSaving', false)
.having((s) => s.error, 'error', isNotNull),
],
);
});
}