feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
@@ -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);
});
}