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? pending; @override Future pick() async { if (error != null) throw KycException(error!); return pending == null ? result : pending!.future; } } class LimitStub implements LimitsService { final requests = >[]; @override Future fetch() { final c = Completer(); requests.add(c); return c.future; } } class BrokenAnalytics implements AnalyticsService { @override Future capture(AnalyticsEvent event) => throw StateError('offline'); } Future 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.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.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.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.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(); 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 rejects(Uint8List bytes, KycError code) async => expectLater( FilePhotoService.validate(bytes), throwsA(isA().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); }); }