276 lines
8.5 KiB
Dart
276 lines
8.5 KiB
Dart
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();
|
|
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);
|
|
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(
|
|
profile: const IdentityProfile(
|
|
fullName: "Example Person",
|
|
dateOfBirth: "1990-04-23",
|
|
residenceCountry: "PL",
|
|
),
|
|
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);
|
|
});
|
|
}
|