feat: complete leave approval MVP
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'attaches refreshed bearer token only to the configured API origin',
|
||||
() async {
|
||||
final captured = <http.Request>[];
|
||||
final client = AuthenticatedHttpClient(
|
||||
inner: MockClient((request) async {
|
||||
captured.add(request);
|
||||
return http.Response('', 200);
|
||||
}),
|
||||
apiOrigin: 'https://api.example.test',
|
||||
tokenProvider: () async => 'refreshed-token',
|
||||
);
|
||||
|
||||
await client.get(Uri.parse('https://api.example.test/api/v1/me'));
|
||||
await client.put(Uri.parse('https://minio.example.test/upload'));
|
||||
|
||||
expect(captured[0].headers['Authorization'], 'Bearer refreshed-token');
|
||||
expect(captured[1].headers['Authorization'], isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('parses server data and UI schemas into safe control models', () {
|
||||
final definition = DynamicFormDefinition.fromJson({
|
||||
'key': 'leave-request',
|
||||
'version': 1,
|
||||
'dataSchema': {
|
||||
r'$id': 'leave-request-v1',
|
||||
'title': '请假申请',
|
||||
'required': ['type'],
|
||||
'properties': {
|
||||
'type': {
|
||||
'type': 'string',
|
||||
'enum': ['PERSONAL', 'SICK'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'uiSchema': {
|
||||
'description': '远程定义',
|
||||
'sections': [
|
||||
{
|
||||
'title': '请假信息',
|
||||
'controls': [
|
||||
{
|
||||
'field': 'type',
|
||||
'label': '请假类型',
|
||||
'control': 'select',
|
||||
'optionLabels': {'PERSONAL': '事假'},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(definition.dataSchema.id, 'leave-request-v1');
|
||||
expect(definition.dataSchema.required, {'type'});
|
||||
expect(definition.dataSchema.properties['type']!.enumValues, [
|
||||
'PERSONAL',
|
||||
'SICK',
|
||||
]);
|
||||
expect(
|
||||
definition.uiSchema.sections.single.controls.single.control,
|
||||
FormControlType.select,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown controls cannot enter the renderer whitelist', () {
|
||||
expect(
|
||||
() => FormControlType.values.byName('remoteScript'),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:convert';
|
||||
import 'package:aioa_mobile/features/assistant/data/leave_progress_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
test('parses ambiguous own request candidates', () async {
|
||||
final repository = LeaveProgressRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/ai/leave-progress-answers');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'requiresSelection': true,
|
||||
'candidates': [
|
||||
{
|
||||
'id': 'leave-1',
|
||||
'type': 'ANNUAL',
|
||||
'status': 'PENDING',
|
||||
'startsAt': '2026-07-20T01:00:00Z',
|
||||
'endsAt': '2026-07-20T09:00:00Z',
|
||||
'createdAt': '2026-07-18T01:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
final result = await repository.ask('我的年假进度?');
|
||||
expect(result.requiresSelection, isTrue);
|
||||
expect(result.candidates.single.id, 'leave-1');
|
||||
});
|
||||
|
||||
test('sends selected request id and parses active tasks', () async {
|
||||
final repository = LeaveProgressRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
client: MockClient((request) async {
|
||||
expect(jsonDecode(request.body)['selectedRequestId'], 'leave-1');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'requiresSelection': false,
|
||||
'answer': '正在主管审批',
|
||||
'request': {
|
||||
'id': 'leave-1',
|
||||
'type': 'ANNUAL',
|
||||
'status': 'PENDING',
|
||||
'startsAt': '2026-07-20T01:00:00Z',
|
||||
'endsAt': '2026-07-20T09:00:00Z',
|
||||
'createdAt': '2026-07-18T01:00:00Z',
|
||||
},
|
||||
'progress': {
|
||||
'activeTaskNames': ['主管审批'],
|
||||
'completedTaskNames': [],
|
||||
'processEnded': false,
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
final result = await repository.ask('进度?', selectedRequestId: 'leave-1');
|
||||
expect(result.activeTasks, ['主管审批']);
|
||||
expect(result.answer, '正在主管审批');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
test('parses a confirmation-required Qwen suggestion', () async {
|
||||
final repository = AiLeaveSuggestionRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient(
|
||||
(request) async => http.Response(
|
||||
jsonEncode({
|
||||
'suggestion': {
|
||||
'type': 'PERSONAL',
|
||||
'startsAt': '2026-07-19T05:30:00Z',
|
||||
'endsAt': '2026-07-19T09:30:00Z',
|
||||
'reason': '办理个人事务',
|
||||
'assumptions': ['下午按 13:30 开始'],
|
||||
'needsClarification': [],
|
||||
},
|
||||
'model': 'qwen-plus',
|
||||
'requiresUserConfirmation': true,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final suggestion = await repository.suggest('明天下午请事假四小时');
|
||||
|
||||
expect(suggestion.values['type'], 'PERSONAL');
|
||||
expect(suggestion.assumptions, ['下午按 13:30 开始']);
|
||||
expect(suggestion.model, 'qwen-plus');
|
||||
});
|
||||
|
||||
test('rejects AI responses that bypass user confirmation', () async {
|
||||
final repository = AiLeaveSuggestionRepository(
|
||||
accessToken: 'token',
|
||||
client: MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'suggestion': {'assumptions': [], 'needsClarification': []},
|
||||
'model': 'qwen-plus',
|
||||
'requiresUserConfirmation': false,
|
||||
}),
|
||||
200,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repository.suggest('请假'),
|
||||
throwsA(isA<AiLeaveSuggestionException>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
const responseBody = {
|
||||
'key': 'leave-request',
|
||||
'version': 1,
|
||||
'dataSchema': {
|
||||
r'$id': 'leave-request-v1',
|
||||
'title': '服务端请假申请',
|
||||
'required': ['reason'],
|
||||
'properties': {
|
||||
'reason': {'type': 'string', 'minLength': 1},
|
||||
},
|
||||
},
|
||||
'uiSchema': {
|
||||
'description': '服务端定义',
|
||||
'sections': [
|
||||
{
|
||||
'title': '说明',
|
||||
'controls': [
|
||||
{'field': 'reason', 'label': '原因', 'control': 'textArea'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('loads remote definition with bearer token and stores cache', () async {
|
||||
late http.Request capturedRequest;
|
||||
final repository = FormDefinitionRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'test-token',
|
||||
client: MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode(responseBody),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
final loaded = await repository.loadLeaveRequest();
|
||||
|
||||
expect(loaded.source, FormDefinitionSource.remote);
|
||||
expect(loaded.definition.dataSchema.title, '服务端请假申请');
|
||||
expect(capturedRequest.headers['Authorization'], 'Bearer test-token');
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
expect(
|
||||
preferences.getString('form-definition.leave-request.v1'),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('uses last valid cache when the network is unavailable', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'form-definition.leave-request.v1': jsonEncode(responseBody),
|
||||
});
|
||||
final repository = FormDefinitionRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
client: MockClient((_) async => throw http.ClientException('offline')),
|
||||
);
|
||||
|
||||
final loaded = await repository.loadLeaveRequest();
|
||||
|
||||
expect(loaded.source, FormDefinitionSource.cache);
|
||||
expect(loaded.definition.uiSchema.description, '服务端定义');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aioa_mobile/features/form/data/leave_attachment_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'creates task, streams bytes to MinIO and completes attachment',
|
||||
() async {
|
||||
final requests = <http.Request>[];
|
||||
final repository = LeaveAttachmentRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.host == 'minio.test') {
|
||||
return http.Response('', 200);
|
||||
}
|
||||
if (request.url.path.endsWith('/upload-tasks')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'attachment': {
|
||||
'id': 'attachment-1',
|
||||
'fileName': 'proof.pdf',
|
||||
'contentType': 'application/pdf',
|
||||
'sizeBytes': 4,
|
||||
'status': 'PENDING',
|
||||
},
|
||||
'uploadUrl': 'https://minio.test/upload/object',
|
||||
}),
|
||||
201,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'id': 'attachment-1',
|
||||
'fileName': 'proof.pdf',
|
||||
'contentType': 'application/pdf',
|
||||
'sizeBytes': 4,
|
||||
'status': 'READY',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
final progress = <double>[];
|
||||
|
||||
final attachment = await repository.upload(
|
||||
leaveRequestId: 'leave-1',
|
||||
fileName: 'proof.pdf',
|
||||
contentType: 'application/pdf',
|
||||
bytes: Uint8List.fromList([1, 2, 3, 4]),
|
||||
onProgress: progress.add,
|
||||
);
|
||||
|
||||
expect(attachment.status, 'READY');
|
||||
expect(requests.map((request) => request.method), [
|
||||
'POST',
|
||||
'PUT',
|
||||
'POST',
|
||||
]);
|
||||
expect(requests[1].bodyBytes, [1, 2, 3, 4]);
|
||||
expect(requests[1].headers['Content-Type'], 'application/pdf');
|
||||
expect(progress.last, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('rejects oversized files before creating an upload task', () async {
|
||||
final repository = LeaveAttachmentRepository(
|
||||
accessToken: 'token',
|
||||
client: MockClient((_) async => fail('request should not be sent')),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repository.upload(
|
||||
leaveRequestId: 'leave-1',
|
||||
fileName: 'large.pdf',
|
||||
contentType: 'application/pdf',
|
||||
bytes: Uint8List(10 * 1024 * 1024 + 1),
|
||||
onProgress: (_) {},
|
||||
),
|
||||
throwsA(isA<LeaveAttachmentException>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/features/form/data/leave_draft_submission_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
const values = {
|
||||
'type': 'PERSONAL',
|
||||
'startsAt': '2026-07-20T01:00:00Z',
|
||||
'endsAt': '2026-07-20T05:00:00Z',
|
||||
'reason': '办理个人事务',
|
||||
};
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('creates a backend draft and clears the pending request', () async {
|
||||
late http.Request captured;
|
||||
final repository = LeaveDraftSubmissionRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient((request) async {
|
||||
captured = request;
|
||||
return http.Response(
|
||||
jsonEncode({'id': 'draft-1', 'status': 'DRAFT', 'version': 0}),
|
||||
201,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
final created = await repository.create(values);
|
||||
|
||||
expect(created.id, 'draft-1');
|
||||
expect(captured.headers['Authorization'], 'Bearer token');
|
||||
expect(captured.headers['Idempotency-Key']!.length, greaterThan(16));
|
||||
final body = jsonDecode(captured.body) as Map<String, Object?>;
|
||||
expect(body['version'], 0);
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
expect(
|
||||
preferences.getString(LeaveDraftSubmissionRepository.pendingStorageKey),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('reuses the same idempotency key after a network failure', () async {
|
||||
final keys = <String>[];
|
||||
var attempts = 0;
|
||||
final repository = LeaveDraftSubmissionRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient((request) async {
|
||||
keys.add(request.headers['Idempotency-Key']!);
|
||||
attempts += 1;
|
||||
if (attempts == 1) throw http.ClientException('offline');
|
||||
return http.Response(
|
||||
jsonEncode({'id': 'draft-1', 'status': 'DRAFT', 'version': 0}),
|
||||
201,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repository.create(values),
|
||||
throwsA(isA<LeaveDraftSubmissionException>()),
|
||||
);
|
||||
final created = await repository.create(values);
|
||||
|
||||
expect(created.id, 'draft-1');
|
||||
expect(keys, hasLength(2));
|
||||
expect(keys[1], keys[0]);
|
||||
});
|
||||
|
||||
test(
|
||||
'surfaces an unauthorized response when no session token is attached',
|
||||
() async {
|
||||
final repository = LeaveDraftSubmissionRepository(
|
||||
client: MockClient(
|
||||
(_) async =>
|
||||
http.Response(jsonEncode({'detail': 'Unauthorized'}), 401),
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repository.create(values),
|
||||
throwsA(
|
||||
isA<LeaveDraftSubmissionException>().having(
|
||||
(error) => error.retryable,
|
||||
'retryable',
|
||||
isTrue,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:aioa_mobile/features/form/data/leave_local_draft_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('persists and restores an unfinished leave draft', () async {
|
||||
final repository = LeaveLocalDraftRepository();
|
||||
await repository.save({
|
||||
'type': 'SICK',
|
||||
'startsAt': '2026-07-20T01:00:00Z',
|
||||
'reason': '身体不适',
|
||||
});
|
||||
|
||||
final restored = await repository.load();
|
||||
|
||||
expect(restored, isNotNull);
|
||||
expect(restored!.values['type'], 'SICK');
|
||||
expect(restored.values['reason'], '身体不适');
|
||||
expect(restored.savedAt.isUtc, isTrue);
|
||||
});
|
||||
|
||||
test('removes corrupted local drafts instead of crashing the form', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
LeaveLocalDraftRepository.storageKey: '{broken-json',
|
||||
});
|
||||
final repository = LeaveLocalDraftRepository();
|
||||
|
||||
expect(await repository.load(), isNull);
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
expect(preferences.getString(LeaveLocalDraftRepository.storageKey), isNull);
|
||||
});
|
||||
|
||||
test('clears a completed or discarded draft', () async {
|
||||
final repository = LeaveLocalDraftRepository();
|
||||
await repository.save({'reason': 'temporary'});
|
||||
|
||||
await repository.clear();
|
||||
|
||||
expect(await repository.load(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
final requestJson = {
|
||||
'id': 'leave-1',
|
||||
'type': 'PERSONAL',
|
||||
'startsAt': '2026-07-20T01:00:00Z',
|
||||
'endsAt': '2026-07-20T05:00:00Z',
|
||||
'reason': '办理个人事务',
|
||||
'status': 'DRAFT',
|
||||
'version': 0,
|
||||
'createdAt': '2026-07-18T08:00:00Z',
|
||||
'updatedAt': '2026-07-18T08:00:00Z',
|
||||
};
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('loads own leave requests', () async {
|
||||
final repository = LeaveRequestRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode([requestJson]),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final requests = await repository.list();
|
||||
|
||||
expect(requests.single.status, 'DRAFT');
|
||||
expect(requests.single.reason, '办理个人事务');
|
||||
});
|
||||
|
||||
test('reuses transition idempotency key after network failure', () async {
|
||||
final keys = <String>[];
|
||||
var attempt = 0;
|
||||
final repository = LeaveRequestRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient((request) async {
|
||||
keys.add(
|
||||
request.headers.entries
|
||||
.firstWhere(
|
||||
(entry) => entry.key.toLowerCase() == 'idempotency-key',
|
||||
orElse: () => const MapEntry('', 'missing'),
|
||||
)
|
||||
.value,
|
||||
);
|
||||
attempt += 1;
|
||||
if (attempt == 1) throw http.ClientException('offline');
|
||||
return http.Response(
|
||||
jsonEncode({...requestJson, 'status': 'PENDING', 'version': 1}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
final request = LeaveRequestItem.fromJson(requestJson);
|
||||
|
||||
await expectLater(
|
||||
repository.transition(request, 'submit'),
|
||||
throwsA(isA<LeaveRequestException>()),
|
||||
);
|
||||
final submitted = await repository.transition(request, 'submit');
|
||||
|
||||
expect(submitted.status, 'PENDING');
|
||||
expect(keys[1], keys[0]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
final taskJson = {
|
||||
'id': 'task-1',
|
||||
'name': '部门主管审批',
|
||||
'createdAt': '2026-07-18T08:00:00Z',
|
||||
'leaveRequest': {
|
||||
'id': 'leave-1',
|
||||
'type': 'PERSONAL',
|
||||
'startsAt': '2026-07-20T01:00:00Z',
|
||||
'endsAt': '2026-07-20T05:00:00Z',
|
||||
'reason': '办理个人事务',
|
||||
'status': 'PENDING',
|
||||
'version': 1,
|
||||
},
|
||||
};
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('loads assigned approval tasks', () async {
|
||||
final repository = ApprovalTaskRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode([taskJson]),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final tasks = await repository.list();
|
||||
|
||||
expect(tasks.single.name, '部门主管审批');
|
||||
expect(tasks.single.leaveRequest.version, 1);
|
||||
});
|
||||
|
||||
test('reuses approval idempotency key after network failure', () async {
|
||||
final keys = <String>[];
|
||||
var attempt = 0;
|
||||
final repository = ApprovalTaskRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient((request) async {
|
||||
keys.add(
|
||||
request.headers.entries
|
||||
.firstWhere(
|
||||
(entry) => entry.key.toLowerCase() == 'idempotency-key',
|
||||
orElse: () => const MapEntry('', 'missing'),
|
||||
)
|
||||
.value,
|
||||
);
|
||||
attempt += 1;
|
||||
if (attempt == 1) throw http.ClientException('offline');
|
||||
return http.Response(
|
||||
jsonEncode(taskJson['leaveRequest']),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
final task = ApprovalTaskItem.fromJson(taskJson);
|
||||
|
||||
await expectLater(
|
||||
repository.decide(task: task, approved: true, comment: '同意'),
|
||||
throwsA(isA<ApprovalTaskException>()),
|
||||
);
|
||||
await repository.decide(task: task, approved: true, comment: '同意');
|
||||
|
||||
expect(keys, hasLength(2));
|
||||
expect(keys[1], keys[0]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
final unreadJson = {
|
||||
'id': 'notification-1',
|
||||
'type': 'LEAVE_APPROVED',
|
||||
'title': '请假申请已通过',
|
||||
'body': '你的请假申请已完成审批。',
|
||||
'resourceType': 'LEAVE_REQUEST',
|
||||
'resourceId': 'leave-1',
|
||||
'createdAt': '2026-07-18T08:00:00Z',
|
||||
'readAt': null,
|
||||
};
|
||||
|
||||
test('loads own notifications with bearer token', () async {
|
||||
late http.Request captured;
|
||||
final repository = NotificationRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient((request) async {
|
||||
captured = request;
|
||||
return http.Response(
|
||||
jsonEncode([unreadJson]),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
final notifications = await repository.list();
|
||||
|
||||
expect(notifications.single.isRead, isFalse);
|
||||
expect(notifications.single.title, '请假申请已通过');
|
||||
expect(captured.headers['Authorization'], 'Bearer token');
|
||||
});
|
||||
|
||||
test('marks a notification read', () async {
|
||||
final repository = NotificationRepository(
|
||||
baseUrl: 'https://api.example.test/api/v1',
|
||||
accessToken: 'token',
|
||||
client: MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({...unreadJson, 'readAt': '2026-07-18T08:05:00Z'}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
final notification = await repository.markRead('notification-1');
|
||||
|
||||
expect(notification.isRead, isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user