Add optional KYC flow and service-backed account limits
This commit is contained in:
1 parent
61ccfc1844
commit
3397f04ab7
38 files changed
+3443
-36
No files matched your search
@@ -0,0 +1,263 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
class Photos implements PhotoService {
|
||||
Photo? result = Photo(Uint8List.fromList([1, 2, 3]));
|
||||
KycError? error;
|
||||
Completer<Photo?>? pending;
|
||||
@override
|
||||
Future<Photo?> pick() async {
|
||||
if (error != null) throw KycException(error!);
|
||||
return pending == null ? result : pending!.future;
|
||||
}
|
||||
}
|
||||
|
||||
class LimitStub implements LimitsService {
|
||||
final requests = <Completer<Limits>>[];
|
||||
@override
|
||||
Future<Limits> fetch() {
|
||||
final c = Completer<Limits>();
|
||||
requests.add(c);
|
||||
return c.future;
|
||||
}
|
||||
}
|
||||
|
||||
class BrokenAnalytics implements AnalyticsService {
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) => throw StateError('offline');
|
||||
}
|
||||
|
||||
Future<void> draft(IdentityProvider p) async {
|
||||
await p.refresh();
|
||||
p.start();
|
||||
await p.pick(PhotoKind.passport);
|
||||
p.next();
|
||||
await p.pick(PhotoKind.selfie);
|
||||
p.next();
|
||||
p.confirm(true);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late DummyIdentityService service;
|
||||
late Photos photos;
|
||||
late DummyAnalyticsService sink;
|
||||
late IdentityProvider p;
|
||||
setUp(() {
|
||||
service = DummyIdentityService(delay: Duration.zero);
|
||||
photos = Photos();
|
||||
sink = DummyAnalyticsService();
|
||||
p = IdentityProvider(service, photos, JourneyRecorder(sink));
|
||||
});
|
||||
tearDown(() => p.dispose());
|
||||
test(
|
||||
'unverified, pending, correction and denial retain basic service limits',
|
||||
() async {
|
||||
final limits = LimitsProvider(DummyLimitsService(service));
|
||||
addTearDown(limits.dispose);
|
||||
await limits.refresh();
|
||||
expect(limits.data!.tier, AccountTier.basic);
|
||||
await draft(p);
|
||||
await p.submit();
|
||||
expect(p.record!.status, IdentityStatus.pending);
|
||||
expect(p.hasDraft, false);
|
||||
await limits.refresh();
|
||||
expect(limits.data!.tier, AccountTier.basic);
|
||||
await p.demoReview(
|
||||
IdentityStatus.changesRequested,
|
||||
reason: ReviewReason.passportExpired,
|
||||
);
|
||||
await limits.refresh();
|
||||
expect(limits.data!.tier, AccountTier.basic);
|
||||
await draft(p);
|
||||
await p.submit();
|
||||
await p.demoReview(
|
||||
IdentityStatus.denied,
|
||||
reason: ReviewReason.unableToVerify,
|
||||
);
|
||||
p.start();
|
||||
expect(p.editing, false);
|
||||
await limits.refresh();
|
||||
expect(limits.data!.tier, AccountTier.basic);
|
||||
},
|
||||
);
|
||||
test('approval refreshes backend limits through callback', () async {
|
||||
final limits = LimitsProvider(DummyLimitsService(service));
|
||||
addTearDown(limits.dispose);
|
||||
final subject = IdentityProvider(
|
||||
service,
|
||||
photos,
|
||||
JourneyRecorder(sink),
|
||||
onStatusChanged: limits.refresh,
|
||||
);
|
||||
addTearDown(subject.dispose);
|
||||
await draft(subject);
|
||||
final before = limits.data!.rows.first.currentMinor;
|
||||
await subject.submit();
|
||||
await subject.demoReview(IdentityStatus.approved);
|
||||
expect(limits.data!.tier, AccountTier.verified);
|
||||
expect(limits.data!.rows.first.currentMinor, greaterThan(before));
|
||||
});
|
||||
test('cancel and invalid replacement preserve photo', () async {
|
||||
await draft(p);
|
||||
p.back();
|
||||
final original = p.selfie;
|
||||
photos.result = null;
|
||||
await p.pick(PhotoKind.selfie);
|
||||
expect(p.selfie, same(original));
|
||||
expect(p.error, null);
|
||||
photos.error = KycError.tooLarge;
|
||||
await p.pick(PhotoKind.selfie);
|
||||
expect(p.selfie, same(original));
|
||||
expect(p.error, KycError.tooLarge);
|
||||
expect(p.step, 1);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sink.events.any((e) => e.signal == JourneySignal.cancelled), true);
|
||||
});
|
||||
test(
|
||||
'submit retry preserves photos and counts one terminal simulated outcome',
|
||||
() async {
|
||||
await draft(p);
|
||||
service.nextError = KycError.unavailable;
|
||||
await p.submit();
|
||||
expect(p.hasDraft, true);
|
||||
expect(p.canSubmit, true);
|
||||
await p.submit();
|
||||
expect(p.record!.status, IdentityStatus.pending);
|
||||
expect(p.hasDraft, false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final events = sink.events.where(
|
||||
(e) => e.step == JourneyStep.kycSubmission,
|
||||
);
|
||||
expect(events.where((e) => e.terminal).length, 1);
|
||||
expect(events.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(events.every((e) => e.simulated), true);
|
||||
expect(
|
||||
sink.events
|
||||
.expand((e) => e.properties.keys)
|
||||
.any(
|
||||
(k) => [
|
||||
'email',
|
||||
'filename',
|
||||
'bytes',
|
||||
'passport',
|
||||
'token',
|
||||
].contains(k),
|
||||
),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
test('duplicate submit suppressed and service idempotency replays', () async {
|
||||
await draft(p);
|
||||
await Future.wait([p.submit(), p.submit()]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(
|
||||
sink.events
|
||||
.where(
|
||||
(e) =>
|
||||
e.step == JourneyStep.kycSubmission &&
|
||||
e.signal == JourneySignal.requested,
|
||||
)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
final replay = await service.submit(
|
||||
passport: photos.result!,
|
||||
selfie: photos.result!,
|
||||
idempotencyKey: p.record!.submissionId!,
|
||||
);
|
||||
expect(replay.submissionId, p.record!.submissionId);
|
||||
});
|
||||
test('status error does not invent a tier or allow submission', () async {
|
||||
service.nextError = KycError.unavailable;
|
||||
await p.refresh();
|
||||
expect(p.record, null);
|
||||
expect(p.stale, true);
|
||||
p.start();
|
||||
expect(p.editing, false);
|
||||
await p.refresh();
|
||||
p.start();
|
||||
expect(p.editing, true);
|
||||
});
|
||||
test(
|
||||
'limits ignore late response and mark retained values stale on error',
|
||||
() async {
|
||||
final s = LimitStub();
|
||||
final limits = LimitsProvider(s);
|
||||
addTearDown(limits.dispose);
|
||||
final old = limits.refresh();
|
||||
final recent = limits.refresh();
|
||||
s.requests[1].complete(
|
||||
Limits(tier: AccountTier.verified, rows: [], simulated: true),
|
||||
);
|
||||
await recent;
|
||||
s.requests[0].complete(
|
||||
Limits(tier: AccountTier.basic, rows: [], simulated: true),
|
||||
);
|
||||
await old;
|
||||
expect(limits.data!.tier, AccountTier.verified);
|
||||
final failure = limits.refresh();
|
||||
s.requests[2].completeError(StateError('offline'));
|
||||
await failure;
|
||||
expect(limits.failed, true);
|
||||
expect(limits.data!.tier, AccountTier.verified);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'exit discards images; analytics errors cannot block submission',
|
||||
() async {
|
||||
await draft(p);
|
||||
p.exit();
|
||||
expect(p.hasDraft, false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sink.events.any((e) => e.signal == JourneySignal.abandoned), true);
|
||||
final subject = IdentityProvider(
|
||||
service,
|
||||
photos,
|
||||
JourneyRecorder(BrokenAnalytics()),
|
||||
);
|
||||
addTearDown(subject.dispose);
|
||||
await draft(subject);
|
||||
await subject.submit();
|
||||
expect(subject.record!.status, IdentityStatus.pending);
|
||||
},
|
||||
);
|
||||
test('late picker does not repopulate disposed state', () async {
|
||||
final subject = IdentityProvider(service, photos, JourneyRecorder(sink));
|
||||
await subject.refresh();
|
||||
subject.start();
|
||||
photos.pending = Completer<Photo?>();
|
||||
final pending = subject.pick(PhotoKind.passport);
|
||||
subject.dispose();
|
||||
photos.pending!.complete(photos.result);
|
||||
await pending;
|
||||
expect(subject.hasDraft, false);
|
||||
});
|
||||
test('validates bytes, signature, corrupt encoding and resolution', () async {
|
||||
Future<void> rejects(Uint8List bytes, KycError code) async => expectLater(
|
||||
FilePhotoService.validate(bytes),
|
||||
throwsA(isA<KycException>().having((e) => e.code, 'code', code)),
|
||||
);
|
||||
await rejects(Uint8List(FilePhotoService.maxBytes + 1), KycError.tooLarge);
|
||||
await rejects(Uint8List.fromList([1, 2, 3]), KycError.format);
|
||||
await rejects(Uint8List.fromList([255, 216, 255, 0]), KycError.unreadable);
|
||||
final r = ui.PictureRecorder();
|
||||
ui.Canvas(r);
|
||||
final pic = r.endRecording();
|
||||
final image = await pic.toImage(20, 20);
|
||||
final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
image.dispose();
|
||||
pic.dispose();
|
||||
await rejects(bytes, KycError.tooSmall);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user