Submit customer identity proofs and show reviewed limits

This commit is contained in:
Stephan D committed 2026-10-15 02:22:28 +02:00
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,
);
},
);
}