Add optional KYC flow and service-backed account limits

This commit is contained in:
Stephan D committed 2026-09-12 09:28:24 +02:00
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);
});
}