Submit customer identity proofs and show reviewed limits
This commit is contained in:
1 parent
7bf8b8087a
commit
1354551ae8
50 files changed
+1777
-67
No files matched your search
@@ -0,0 +1,161 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
String response({String status = 'pending'}) => jsonEncode({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'status': status,
|
||||
'submissionId': 'submission',
|
||||
'profile': {
|
||||
'fullName': 'Legal Name',
|
||||
'dateOfBirth': '1990-04-23',
|
||||
'residenceCountry': 'PL',
|
||||
},
|
||||
},
|
||||
});
|
||||
void main() {
|
||||
test('an approved review with an unfinished upgrade stays pending and cannot resubmit', () async {
|
||||
final payload = jsonDecode(response()) as Map<String, dynamic>;
|
||||
(payload['data'] as Map<String, dynamic>)['upgradePending'] = true;
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient((_) async => http.Response(jsonEncode(payload), 200)),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.fetch();
|
||||
expect(result.status, IdentityStatus.pending);
|
||||
expect(result.upgradePending, true);
|
||||
expect(result.canSubmit, false);
|
||||
});
|
||||
test(
|
||||
'uploads profile plus both files, credentials scoped to current session',
|
||||
() async {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test/api/v1'),
|
||||
accessToken: () => 'session-token',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/accounts/identity');
|
||||
expect(request.headers['Authorization'], 'Bearer session-token');
|
||||
expect(request.followRedirects, false);
|
||||
expect(
|
||||
request.headers['content-type'],
|
||||
contains('multipart/form-data'),
|
||||
);
|
||||
expect(request.body, contains('name="document"'));
|
||||
expect(request.body, contains('name="selfie"'));
|
||||
expect(request.body, contains('"fullName":"Legal Name"'));
|
||||
expect(request.body, contains('stable-key'));
|
||||
expect(request.body, isNot(contains('customer_ref')));
|
||||
return http.Response(response(), 200);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.submit(
|
||||
profile: const IdentityProfile(
|
||||
fullName: 'Legal Name',
|
||||
dateOfBirth: '1990-04-23',
|
||||
residenceCountry: 'PL',
|
||||
),
|
||||
passport: Photo(Uint8List.fromList([1])),
|
||||
selfie: Photo(Uint8List.fromList([2])),
|
||||
idempotencyKey: 'stable-key',
|
||||
);
|
||||
expect(result.status, IdentityStatus.pending);
|
||||
expect(result.simulated, false);
|
||||
},
|
||||
);
|
||||
test('session switch rejects a late response', () async {
|
||||
var token = 'first';
|
||||
final pending = Completer<http.Response>();
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => token,
|
||||
client: MockClient((_) => pending.future),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = service.fetch();
|
||||
token = 'second';
|
||||
pending.complete(http.Response(response(), 200));
|
||||
await expectLater(
|
||||
result,
|
||||
throwsA(
|
||||
isA<KycException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
KycError.sessionExpired,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
test(
|
||||
'production limits preserve exact decimals and never use demo rows',
|
||||
() async {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/accounts/limits');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'tier': 'verified',
|
||||
'profileRef': 'verified-policy',
|
||||
'entries': [
|
||||
{
|
||||
'operation': 'Transfer',
|
||||
'period': 'day',
|
||||
'currency': 'BTC',
|
||||
'cap': '0.12345678',
|
||||
'remaining': '0.10000001',
|
||||
'used': '0.02',
|
||||
'reserved': '0.00345677',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await BackendLimitsService(service).fetch();
|
||||
expect(result.rows, isEmpty);
|
||||
expect(result.simulated, false);
|
||||
expect(result.entries.single.cap, '0.12345678');
|
||||
expect(result.tier, AccountTier.verified);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'unavailable, conflict and expired sessions retain typed recovery',
|
||||
() async {
|
||||
for (final pair in [
|
||||
(503, KycError.unavailable),
|
||||
(409, KycError.conflict),
|
||||
(401, KycError.sessionExpired),
|
||||
]) {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient(
|
||||
(_) async => http.Response('private upstream details', pair.$1),
|
||||
),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
await expectLater(
|
||||
service.fetch(),
|
||||
throwsA(isA<KycException>().having((e) => e.code, 'code', pair.$2)),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,13 @@ class BrokenAnalytics implements AnalyticsService {
|
||||
Future<void> draft(IdentityProvider p) async {
|
||||
await p.refresh();
|
||||
p.start();
|
||||
p.updateProfile(
|
||||
const IdentityProfile(
|
||||
fullName: "Example Person",
|
||||
dateOfBirth: "1990-04-23",
|
||||
residenceCountry: "PL",
|
||||
),
|
||||
);
|
||||
await p.pick(PhotoKind.passport);
|
||||
p.next();
|
||||
await p.pick(PhotoKind.selfie);
|
||||
@@ -170,6 +177,11 @@ void main() {
|
||||
1,
|
||||
);
|
||||
final replay = await service.submit(
|
||||
profile: const IdentityProfile(
|
||||
fullName: "Example Person",
|
||||
dateOfBirth: "1990-04-23",
|
||||
residenceCountry: "PL",
|
||||
),
|
||||
passport: photos.result!,
|
||||
selfie: photos.result!,
|
||||
idempotencyKey: p.record!.submissionId!,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
class RealIdentity extends DummyIdentityService {
|
||||
IdentityStatus status = IdentityStatus.unverified;
|
||||
bool unavailable = false;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
@override
|
||||
Future<Identity> fetch() async {
|
||||
if (unavailable) throw const KycException(KycError.unavailable);
|
||||
return Identity(status: status);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('offers optional verification once to a basic customer and allows later corrections', () async {
|
||||
final service = RealIdentity(),
|
||||
provider = IdentityOnboardingProvider(RealIdentity());
|
||||
expect(await provider.offer('customer'), true);
|
||||
expect(await provider.offer('customer'), false);
|
||||
final subject = IdentityOnboardingProvider(service);
|
||||
service.status = IdentityStatus.pending;
|
||||
expect(await subject.offer('pending'), false);
|
||||
service.status = IdentityStatus.approved;
|
||||
expect(await subject.offer('approved'), false);
|
||||
service.status = IdentityStatus.changesRequested;
|
||||
expect(await subject.offer('correction'), true);
|
||||
});
|
||||
test(
|
||||
'a failed optional identity dependency never blocks basic login',
|
||||
() async {
|
||||
final service = RealIdentity()..unavailable = true;
|
||||
expect(
|
||||
await IdentityOnboardingProvider(service).offer('customer'),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user