1 Commits
Author SHA1 Message Date
Stephan D 62df9eac5f Merge remote-tracking branch 'origin/main' into wallet-66 2026-10-23 12:17:10 +02:00
29 changed files with 55 additions and 1292 deletions

No files matched your search

-63
View File
@@ -296,66 +296,3 @@ real token was checked read-only and remains unused and unexpired, with its
account pending verification, ready for the user to reopen the link. PR #61 has account pending verification, ready for the user to reopen the link. PR #61 has
not been merged into remote main; this local deployment does not imply a main not been merged into remote main; this local deployment does not imply a main
branch merge. branch merge.
### Accepted signup delivery (#62)
Task [#62](https://git.sendico.io/tech/sxchnge/issues/62), branch
`email-verification-62`, delivered in
[PR #63](https://git.sendico.io/tech/sxchnge/pulls/63). On October 23, signup
returned HTTP 202 and the local
SMTP provider accepted its message with status 250, while the user reported an
internal-error screen immediately after Create account. No subsequent
verification request from that attempt reached the BFF. The precise original
client exception was not captured; an ordinary isolated release reproduction
succeeded. The regression below deliberately injects an unavailable follow-up
send service to cover the post-acceptance failure path without claiming that
injected exception was the original cause.
Backend and preview signup mappers now return a `VerificationDelivery` domain
receipt with the recipient and simulation status. It means that signup accepted
the email-queue operation, not inbox delivery, account activation or a session.
The signup provider exposes it through its operation result. The form captures
the receipt before clearing password controllers, whose change listeners reset
auth state, and passes it as transient router extra. The verification provider
initializes directly in the sent state; mounting the page performs no send.
This replaces the backend adapter's hidden one-use signup cache. Every explicit
resend now invokes its service, including the first one after signup.
Routes without a receipt retain their existing behavior: unverified login may
request an email, a fresh verification page asks for an address, and emailed
links run confirmation. Recipient changes invalidate the displayed claim until
another send succeeds. New receipts replace stale recipients. The route codec
discards the receipt when serializing history, so reloads do not invent a queued
result or retain the address. No token or password is included in the receipt.
The existing email-delivery requested/succeeded signals now follow the accepted
signup receipt inside the instrumented service instead of a synthetic send on
page mount. They stay in the signup journey, occur once for the accepted
operation, and remain intermediate queue-acceptance signals. Resends retain
separate attempts and failures; confirmation remains the terminal outcome.
Simulation and privacy rules are unchanged, including the in-memory analytics
and cross-device correlation limitations above.
Frontend review covered the complete app/auth contribution and preserved the
DTO, mapper, service, domain model, provider and UI boundaries. Regressions cover
accepted signup followed by unavailable resend, explicit retry, normalized
receipt mapping, provider initialization/reset, recipient replacement, transient
history, event correlation and exclusion of form values from analytics. EN/RU
flow tests run at 390px with 1.5x text scaling; existing keyboard, autofill and
accessibility suites also pass. Changed-file Dart format checks, analysis in
`frontend/app` and `frontend/packages/sxchnge_auth`, and `make test-frontend`
(221 tests) pass. The new injected-failure regression failed on the previous
implementation and passes with the receipt handoff. A release-browser fixture
with the same backend adapter and a failing resend service shows Check your
inbox after signup with no send call, then the expected error only after an
explicit resend. It sends no email. Native Safari was not automated.
The frontend image was rebuilt with freshly resolved stable digest pins and
recreated without restarting BFF. The served JavaScript matches the new image
at SHA-256 `0a45ce6f0b4658b1f03d8e39b9e39fd3fbfcf6f1626cb3f17705dce2da130a6c`.
Normal browser-trust HTTPS serves the frontend and API with HTTP 200.
`sh infra/local/tools.sh verify` passes all seven services, Vault/database
checks and authenticated Sendico currency/ledger reads. No new customer email,
activation or payment was submitted for this change, and no security scan was
requested or run. The user's pre-existing browser tab must reload to use the
new bundle; their original Safari exception remains unobserved.
-48
View File
@@ -211,51 +211,3 @@ smoke testing on the local demo opened the selector, searched for Poland and
confirmed that the profile displays Poland after selection. Focused and full confirmed that the profile displays Poland after selection. Focused and full
journey tests verify the stored `PL` code. Generated localization/build outputs journey tests verify the stored `PL` code. Generated localization/build outputs
remain ignored and uncommitted. remain ignored and uncommitted.
## Birthday entry — task #67
Task [#67](https://git.sendico.io/tech/sxchnge/issues/67), branch `kyc-67`.
Delivery: [PR #70](https://git.sendico.io/tech/sxchnge/pulls/70).
This change is stacked on the residence-country selector in PR #65; merge #65
before the birthday PR.
The profile uses labeled Day, Month and Year controls. Day and year accept numeric
entry with birthday autofill hints; month selection uses localized month names
and standard Material keyboard/touch interaction. Customers can enter their birth
year directly. No calendar paging or transport-format punctuation is required.
The controls share a row when space allows and stack at narrow widths or enlarged
text sizes. English and Russian labels, month names and errors follow the app locale.
Valid calendar dates are normalized to the existing `IdentityProfile.dateOfBirth`
`YYYY-MM-DD` value through `IdentityProvider.updateProfile`. Partial, impossible
and future dates clear that profile value so Continue cannot retain an earlier
valid birthday. Local input components remain visible while correcting a date;
complete invalid dates receive a specific live-region error below the group.
Leap years, zero day/year and calendar rollover are checked. Today's date follows
the existing model policy; no minimum age or arbitrary oldest birth year is added.
Prefilled dates, locale changes and external profile replacement synchronize the
controls. Back navigation restores the valid saved date. Busy state disables all
three controls. Controllers are disposed with the widget.
The existing DTO, mapper, service, domain model and provider boundaries are retained.
No dependency, transport or backend change is needed. This remains part of the KYC
preparation-to-submission journey defined above: existing acceptance, failure,
retry and abandonment signals apply, without new field events or date collection.
The existing server-side review measurement gaps remain unchanged.
UX review and automated checks cover entry/correction, leap dates, invalid and
future-date recovery, numeric restrictions, prefill/replacement/clearing, locale
switching, disabled state, Tab/Enter/arrow-key month selection, Escape cancellation,
and Russian controls/errors at 320px with 1.6 text scale. Labeled and minimum-size
tap-target guidelines pass. Integration checks verify that invalid dates block
Continue, Back preserves the birthday, and EN/RU flows still reach submission
acceptance. Native screen-reader and mobile keyboard/autofill testing remain
release checks; widget semantics are not a claim of device-level validation.
Validation: all four changed Dart files pass the format check; `flutter analyze`
in `frontend/app` reports no issues; `make test-frontend` passes all 230 tests,
including the seven new birthday widget tests and the new navigation/gating test.
`flutter build web` passes, including its Wasm compatibility dry run. Browser
smoke testing of the release build entered 23 April 1990, verified the inline
error for 31 April and restored 23 April without losing month or year.
Generated localization and build outputs remain ignored and uncommitted.
-98
View File
@@ -1,98 +0,0 @@
# Identity photo preview correction
Task [#69](https://git.sendico.io/tech/sxchnge/issues/69), branch `kyc-69`.
Delivery: [PR #71](https://git.sendico.io/tech/sxchnge/pulls/71).
This frontend change builds on the [identity verification journey](kyc.md)
and is independent of the birthday controls in task #67.
## Cause and correction
A synthetic valid PNG reproduced this Flutter 3.47.7 browser error:
```text
TypeError: Instance of '_UnmodifiableUint8ListView': type
'_UnmodifiableUint8ListView' is not a subtype of type 'NativeUint8List'
```
The stack reached `Uint8ListToJSUint8Array.toJS` from CanvasKit's browser
image decoder through `MemoryImage` and `ResizeImage`. File selection had
successfully decoded the original typed bytes. The `Photo` constructor then
copied them into an unmodifiable view, which was passed directly into the
preview's web decoder. Native decoding accepted that view, hiding the bug in
native-only tests. The generic image error builder presented the failure as a
damaged document even though the selected bytes were valid.
`PhotoPreview` now creates a native `Uint8List` copy for `MemoryImage` once per
selected photo. The shared domain model stays immutable, and validation,
providers, services and transport contracts remain unchanged. The preview still
requests a decode width of at most 1000 pixels and evicts its decoded cache entry
on replacement or disposal. The extra encoded-byte copy belongs to the preview
and is released with its provider/cache references; no secure-erasure guarantee
is made.
An actual preview failure now says that the preview could not be displayed and
suggests selecting the photo again. The English/Russian fallback keeps at least
the preview's usual height, can grow for long text, and has a separate live-region
semantic node. Existing replacement/removal controls stay available and selected
input is retained. This is distinct from validation's corrupt-file message.
## UX and measurement review
The outcome remains selecting readable ID and selfie photos, checking both on
review, and submitting for confirmed service acceptance. Existing KYC journey
attempts, retries, failure, submission and abandonment signals are reused. No
control-level events, image bytes, filenames, form values or raw exceptions are
added to telemetry. A selection success measures validation; it does not establish
that pixels were painted. Preview-only rendering failures are not separately
counted by existing telemetry. Server review/approval measurement and native
screen-reader QA retain the limitations documented in the original journey.
Regression tests use synthetic JPEG/PNG content only. They start and await real
image decoding outside the widget test's fake async zone, inspect the actual
rendered images, and surface stream errors so an error fallback cannot conceal a
failure. Checks cover portrait/landscape decoding, model immutability, bounded
decode width, stable provider reuse, replacement/removal cache eviction, both
review slots, returning to review, localized fallback/replacement, a 320px view
at 1.6 text scale, and error announcements isolated from surrounding instructions.
The `image` package is already a KYC runtime dependency; its direct app test
dependency is used only to create synthetic fixtures in memory.
## Validation and delivery
The original browser regression failed before the fix while its native equivalent
passed. The complete task diff was reviewed against frontend architecture, UX,
measurement, lifecycle and privacy requirements. Final validation passed:
- In `frontend/app`, `dart format --output=none --set-exit-if-changed
lib/kyc/preview.dart test/preview_test.dart`: two files, no changes.
- In `frontend/app`, `flutter analyze --no-pub`: no issues.
- In `frontend/app`, `flutter test --platform chrome test/preview_test.dart
--reporter expanded --no-pub --verbose`: all six regression tests passed.
- At the repository root, `make test-frontend`: all 228 tests passed across
the shared packages, theme example and app, including the six native preview
regressions and existing KYC journey/analytics tests.
- In `frontend/app`, `flutter build web --no-pub`: release build and Wasm
compatibility dry run succeeded.
Browser regression coverage uses Chrome; Safari and native assistive-technology
testing remain release checks. Generated localization and build code stays
ignored; the tracked app lockfile changes only `image` from transitive to direct
test dependency. The separate birthday worktree was preserved.
## Follow-up contribution review
The complete seven-file contribution was reviewed again before the authorized
merge and frontend rollout. No additional defects were found in the byte-copy
boundary, provider reuse, cache disposal, error semantics/localization, privacy,
architecture or regression coverage. No source changes were needed.
Current `main` (`ec4115d`, including the birthday, signup-delivery and account
setup changes) was merged into `kyc-69` before final verification. The two-file
format check and app analyzer passed; all six Chrome preview regressions and
all 240 tests in `make test-frontend` passed on the combined code. The original
228-test result above describes the earlier isolated branch.
The authorized rollout rebuilds only `frontend` from clean, pulled `main`, using
the repository's fresh stable-release resolver and digest-pinned local inputs.
Runtime image, HTTPS and served-bundle verification are recorded in task #69
after the rollout; generated local pins remain ignored.
+1 -2
View File
@@ -99,11 +99,10 @@ class _AuthFormState extends State<AuthForm> {
return; return;
} }
final email = _email.text.trim(); final email = _email.text.trim();
final delivery = signup.state.result!.delivery;
TextInput.finishAutofillContext(); TextInput.finishAutofillContext();
_password.clear(); _password.clear();
_confirmation.clear(); _confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email); context.goNamed(AppRoutes.verificationSent, extra: email);
} else { } else {
final login = context.read<LoginProvider>(); final login = context.read<LoginProvider>();
await login.login( await login.login(
-206
View File
@@ -1,206 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../generated/i18n/app_localizations.dart';
/// Birthday entry with a canonical date string at the profile boundary.
class BirthField extends StatefulWidget {
const BirthField({
super.key,
required this.value,
required this.onChanged,
this.enabled = true,
});
final String value;
final ValueChanged<String> onChanged;
final bool enabled;
@override
State<BirthField> createState() => _BirthFieldState();
}
class _BirthFieldState extends State<BirthField> {
final _day = TextEditingController();
final _year = TextEditingController();
final _monthField = GlobalKey<FormFieldState<int>>();
int? _month;
late String _emitted;
@override
void initState() {
super.initState();
_readValue();
}
@override
void didUpdateWidget(BirthField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _emitted) {
_readValue();
_monthField.currentState?.didChange(_month);
}
}
void _readValue() {
_emitted = widget.value;
final parts = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(widget.value);
_year.text = parts?.group(1) ?? '';
final month = int.tryParse(parts?.group(2) ?? '');
_month = month != null && month >= 1 && month <= 12 ? month : null;
_day.text = parts?.group(3) ?? '';
}
DateTime? get _date {
final day = int.tryParse(_day.text);
final year = int.tryParse(_year.text);
if (day == null ||
year == null ||
year < 1 ||
_year.text.length != 4 ||
_month == null) {
return null;
}
final date = DateTime(year, _month!, day);
return date.year == year && date.month == _month && date.day == day
? date
: null;
}
bool _future(DateTime date) =>
date.isAfter(DateUtils.dateOnly(DateTime.now()));
void _change() {
setState(() {});
final date = _date;
_emitted = date == null || _future(date)
? ''
: '${_year.text}-${_month.toString().padLeft(2, '0')}-'
'${_day.text.padLeft(2, '0')}';
widget.onChanged(_emitted);
}
@override
void dispose() {
_day.dispose();
_year.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final date = _date;
final complete =
_day.text.isNotEmpty && _month != null && _year.text.length == 4;
final error = !complete
? null
: date == null
? l.kycBirthInvalid
: _future(date)
? l.kycBirthFuture
: null;
final months = DateFormat.MMMM(Localizations.localeOf(context).toString());
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycBirth, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
LayoutBuilder(
builder: (context, constraints) {
final day = _number(
key: 'birth-day',
controller: _day,
label: l.kycBirthDay,
length: 2,
autofill: AutofillHints.birthdayDay,
);
final month = DropdownButtonFormField<int>(
key: _monthField,
initialValue: _month,
isExpanded: true,
decoration: InputDecoration(labelText: l.kycBirthMonth),
items: [
for (var month = 1; month <= 12; month++)
DropdownMenuItem(
value: month,
child: Text(months.format(DateTime(2000, month))),
),
],
onChanged: !widget.enabled
? null
: (value) {
_month = value;
_change();
},
);
final year = _number(
key: 'birth-year',
controller: _year,
label: l.kycBirthYear,
length: 4,
autofill: AutofillHints.birthdayYear,
);
final scale = MediaQuery.textScalerOf(context).scale(16) / 16;
if (constraints.maxWidth < 400 * scale) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
day,
const SizedBox(height: 16),
month,
const SizedBox(height: 16),
year,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 2, child: day),
const SizedBox(width: 12),
Expanded(flex: 4, child: month),
const SizedBox(width: 12),
Expanded(flex: 3, child: year),
],
);
},
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Semantics(
liveRegion: true,
child: Text(
error,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
),
],
);
}
Widget _number({
required String key,
required TextEditingController controller,
required String label,
required int length,
required String autofill,
}) => TextFormField(
key: ValueKey(key),
controller: controller,
enabled: widget.enabled,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
autofillHints: [autofill],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(length),
],
decoration: InputDecoration(labelText: label),
onChanged: (_) => _change(),
);
}
+4 -18
View File
@@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
@@ -17,12 +15,8 @@ class PhotoPreview extends StatefulWidget {
class _PhotoPreviewState extends State<PhotoPreview> { class _PhotoPreviewState extends State<PhotoPreview> {
late ImageProvider _image = _provider(); late ImageProvider _image = _provider();
ImageProvider _provider() => ResizeImage.resizeIfNeeded( ImageProvider _provider() =>
1000, ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
null,
// Web decoders require a native typed array, not the model's read-only view.
MemoryImage(Uint8List.fromList(widget.photo.bytes)),
);
@override @override
void didUpdateWidget(covariant PhotoPreview oldWidget) { void didUpdateWidget(covariant PhotoPreview oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -44,15 +38,7 @@ class _PhotoPreviewState extends State<PhotoPreview> {
height: 220, height: 220,
fit: BoxFit.contain, fit: BoxFit.contain,
semanticLabel: widget.label, semanticLabel: widget.label,
errorBuilder: (_, _, _) => ConstrainedBox( errorBuilder: (_, _, _) =>
constraints: const BoxConstraints(minHeight: 220), Text(AppLocalizations.of(context)!.kycErrorUnreadable),
child: Center(
child: Semantics(
container: true,
liveRegion: true,
child: Text(AppLocalizations.of(context)!.kycErrorPreview),
),
),
),
); );
} }
+9 -4
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'birth.dart';
import 'country.dart'; import 'country.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
@@ -38,11 +37,17 @@ class ProfileForm extends StatelessWidget {
onChanged: (value) => update(name: value), onChanged: (value) => update(name: value),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
BirthField( TextFormField(
key: const ValueKey('kyc-birth'), key: const ValueKey('kyc-birth'),
value: p.profile.dateOfBirth, initialValue: p.profile.dateOfBirth,
enabled: !p.busy, enabled: !p.busy,
onChanged: (value) => update(birth: value), keyboardType: TextInputType.datetime,
autofillHints: const [AutofillHints.birthday],
decoration: InputDecoration(
labelText: l.kycBirth,
helperText: l.kycBirthHelp,
),
onChanged: (value) => update(birth: value.trim()),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
CountryField( CountryField(
+1 -6
View File
@@ -128,7 +128,6 @@
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.", "kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.", "kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.", "kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
"kycErrorPreview": "The photo preview could not be displayed. Try choosing the photo again.",
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.", "kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.", "kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.", "kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
@@ -270,11 +269,7 @@
"kycProfileHelp": "Enter your details exactly as shown on your identity document.", "kycProfileHelp": "Enter your details exactly as shown on your identity document.",
"kycFullName": "Full legal name", "kycFullName": "Full legal name",
"kycBirth": "Date of birth", "kycBirth": "Date of birth",
"kycBirthDay": "Day", "kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
"kycBirthMonth": "Month",
"kycBirthYear": "Year",
"kycBirthInvalid": "Enter a valid date of birth.",
"kycBirthFuture": "Date of birth cannot be in the future.",
"kycCountry": "Country of residence", "kycCountry": "Country of residence",
"kycCountrySelect": "Select a country", "kycCountrySelect": "Select a country",
"kycCountrySearch": "Search countries", "kycCountrySearch": "Search countries",
+1 -6
View File
@@ -128,7 +128,6 @@
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.", "kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.", "kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.", "kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
"kycErrorPreview": "Не удалось показать фото. Попробуйте выбрать его ещё раз.",
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.", "kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.", "kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.", "kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
@@ -270,11 +269,7 @@
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.", "kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
"kycFullName": "Полное имя по документу", "kycFullName": "Полное имя по документу",
"kycBirth": "Дата рождения", "kycBirth": "Дата рождения",
"kycBirthDay": "День", "kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
"kycBirthMonth": "Месяц",
"kycBirthYear": "Год",
"kycBirthInvalid": "Укажите корректную дату рождения.",
"kycBirthFuture": "Дата рождения не может быть в будущем.",
"kycCountry": "Страна проживания", "kycCountry": "Страна проживания",
"kycCountrySelect": "Выберите страну", "kycCountrySelect": "Выберите страну",
"kycCountrySearch": "Поиск страны", "kycCountrySearch": "Поиск страны",
-5
View File
@@ -4,8 +4,6 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../auth/page.dart'; import '../auth/page.dart';
@@ -146,9 +144,6 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
name: AppRoutes.verificationSent, name: AppRoutes.verificationSent,
builder: (context, state) => VerificationPage( builder: (context, state) => VerificationPage(
email: state.extra is String ? state.extra as String : null, email: state.extra is String ? state.extra as String : null,
delivery: state.extra is VerificationDelivery
? state.extra as VerificationDelivery
: null,
), ),
), ),
GoRoute( GoRoute(
+7 -23
View File
@@ -15,10 +15,9 @@ import '../routing/routes.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class VerificationPage extends StatefulWidget { class VerificationPage extends StatefulWidget {
const VerificationPage({super.key, this.email, this.token, this.delivery}); const VerificationPage({super.key, this.email, this.token});
final String? email; final String? email;
final String? token; final String? token;
final VerificationDelivery? delivery;
@override @override
State<VerificationPage> createState() => _VerificationPageState(); State<VerificationPage> createState() => _VerificationPageState();
@@ -26,12 +25,9 @@ class VerificationPage extends StatefulWidget {
class _VerificationPageState extends State<VerificationPage> { class _VerificationPageState extends State<VerificationPage> {
final _form = GlobalKey<FormState>(); final _form = GlobalKey<FormState>();
late final _email = TextEditingController( late final _email = TextEditingController(text: widget.email);
text: widget.delivery?.email ?? widget.email,
);
late final _provider = VerificationProvider( late final _provider = VerificationProvider(
context.read<VerificationService>(), context.read<VerificationService>(),
delivery: widget.delivery,
); );
final _emailFocus = FocusNode(); final _emailFocus = FocusNode();
String? _recipient; String? _recipient;
@@ -48,7 +44,7 @@ class _VerificationPageState extends State<VerificationPage> {
if (!mounted) return; if (!mounted) return;
if (widget.token != null) { if (widget.token != null) {
_provider.confirm(widget.token!); _provider.confirm(widget.token!);
} else if (widget.delivery == null && _email.text.isNotEmpty) { } else if (_email.text.isNotEmpty) {
_send(); _send();
} }
}); });
@@ -62,26 +58,14 @@ class _VerificationPageState extends State<VerificationPage> {
@override @override
void didUpdateWidget(covariant VerificationPage oldWidget) { void didUpdateWidget(covariant VerificationPage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.token == widget.token && if (oldWidget.token == widget.token) return;
oldWidget.email == widget.email &&
identical(oldWidget.delivery, widget.delivery)) {
return;
}
if (oldWidget.token != widget.token) {
context.read<JourneyRecorder>().abandon(JourneyExit.navigation); context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
} _provider.reset();
_provider.reset(delivery: widget.delivery);
_lastWasSend = false; _lastWasSend = false;
_validated = false; _validated = false;
_recipient = null; _email.clear();
_email.text = widget.delivery?.email ?? widget.email ?? '';
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (mounted && widget.token != null) _provider.confirm(widget.token!);
if (widget.token != null) {
_provider.confirm(widget.token!);
} else if (widget.delivery == null && _email.text.isNotEmpty) {
_send();
}
}); });
} }
+2 -2
View File
@@ -206,7 +206,7 @@ packages:
source: hosted source: hosted
version: "18.0.1" version: "18.0.1"
http: http:
dependency: "direct dev" dependency: transitive
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -222,7 +222,7 @@ packages:
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image: image:
dependency: "direct dev" dependency: transitive
description: description:
name: image name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
-2
View File
@@ -29,11 +29,9 @@ dependencies:
go_router: ^18.0.1 go_router: ^18.0.1
country_picker: ^2.0.28 country_picker: ^2.0.28
dev_dependencies: dev_dependencies:
image: ^4.9.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
http: ^1.6.0
flutter: flutter:
generate: true generate: true
uses-material-design: true uses-material-design: true
-236
View File
@@ -1,236 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
void main() {
final day = find.byKey(const ValueKey('birth-day'));
final year = find.byKey(const ValueKey('birth-year'));
final month = find.byType(DropdownButtonFormField<int>);
Future<void> mount(
WidgetTester t,
ValueNotifier<String> value, {
Locale locale = const Locale('en'),
bool enabled = true,
}) async {
await t.pumpWidget(
MaterialApp(
theme: SxchngeTheme.light(),
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ValueListenableBuilder<String>(
valueListenable: value,
builder: (_, date, _) => BirthField(
value: date,
enabled: enabled,
onChanged: (date) => value.value = date,
),
),
),
),
),
);
await t.pumpAndSettle();
}
Future<void> select(WidgetTester t, String name) async {
await t.ensureVisible(month);
await t.tap(month);
await t.pumpAndSettle();
await t.tap(find.text(name).last);
await t.pumpAndSettle();
}
testWidgets('enters and corrects a birthday without ISO syntax', (t) async {
final value = ValueNotifier('');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('Date of birth'), findsOneWidget);
await t.enterText(day, '23');
await select(t, 'April');
expect(value.value, '');
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-23');
await t.enterText(day, '9');
await t.pumpAndSettle();
expect(value.value, '1990-04-09');
await t.enterText(year, '19');
await t.pumpAndSettle();
expect(value.value, '');
// A provider rebuild must retain the other components of an incomplete date.
expect(t.widget<TextFormField>(day).controller!.text, '9');
expect(find.text('April'), findsOneWidget);
await t.enterText(year, '1985');
await t.pumpAndSettle();
expect(value.value, '1985-04-09');
});
testWidgets('leap dates, impossible days and future dates recover inline', (
t,
) async {
final value = ValueNotifier('2000-02-29');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('February'), findsOneWidget);
await t.enterText(year, '2001');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
expect(t.widget<TextFormField>(day).controller!.text, '29');
await t.enterText(year, '2004');
await t.pumpAndSettle();
expect(value.value, '2004-02-29');
expect(find.text('Enter a valid date of birth.'), findsNothing);
await t.enterText(day, '31');
await select(t, 'April');
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '30');
await t.enterText(year, '${DateTime.now().year + 1}');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Date of birth cannot be in the future.'), findsOneWidget);
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-30');
await t.enterText(day, '0');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '1');
await t.enterText(year, '0000');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
});
testWidgets(
'prefill, external replacement, locale change and clear synchronize',
(t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
expect(t.widget<TextFormField>(year).controller!.text, '1990');
value.value = '1985-12-09';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '09');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(find.text('December'), findsOneWidget);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('Дата рождения'), findsOneWidget);
expect(find.text('декабрь'), findsOneWidget);
expect(value.value, '1985-12-09');
await select(t, 'апрель');
expect(value.value, '1985-04-09');
value.value = '';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '');
expect(t.widget<TextFormField>(year).controller!.text, '');
expect(find.text('апрель'), findsNothing);
},
);
testWidgets('digits and length limits keep each component usable', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.enterText(day, '');
await t.enterText(day, 'a123');
await t.enterText(year, '');
await t.enterText(year, 'b19856');
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '12');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(value.value, '1985-04-12');
});
testWidgets('busy state disables every birthday control', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value, enabled: false);
expect(t.widget<TextFormField>(day).enabled, isFalse);
expect(t.widget<TextFormField>(year).enabled, isFalse);
expect(t.widget<DropdownButtonFormField<int>>(month).onChanged, isNull);
expect(value.value, '1990-04-23');
});
testWidgets('keyboard can traverse, choose month, cancel and edit year', (
t,
) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: day, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.escape);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: year, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
});
testWidgets(
'Russian controls and errors fit 320px with large text and labeled targets',
(t) async {
t.view.physicalSize = const Size(320, 900);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
final semantics = t.ensureSemantics();
final value = ValueNotifier('1990-09-23');
addTearDown(value.dispose);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('сентябрь'), findsOneWidget);
expect(t, meetsGuideline(labeledTapTargetGuideline));
expect(t, meetsGuideline(androidTapTargetGuideline));
await t.enterText(day, '31');
await t.pumpAndSettle();
expect(find.text('Укажите корректную дату рождения.'), findsOneWidget);
await select(t, 'март');
expect(value.value, '1990-03-31');
expect(t.takeException(), isNull);
semantics.dispose();
},
);
}
+2 -48
View File
@@ -11,7 +11,6 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/kyc/country.dart'; import 'package:sxchnge_app/kyc/country.dart';
import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart'; import 'package:sxchnge_app/providers/locale.dart';
@@ -67,16 +66,10 @@ void main() {
return service; return service;
} }
Future<void> profile(WidgetTester t) async { Future<void> submit(WidgetTester t) async {
await tap(t, find.byKey(const ValueKey('kyc-start'))); await tap(t, find.byKey(const ValueKey('kyc-start')));
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person'); await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
await t.enterText(find.byKey(const ValueKey('birth-day')), '23'); await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
await tap(t, find.byType(DropdownButtonFormField<int>));
// The month label follows the selected app locale.
await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last);
await t.enterText(find.byKey(const ValueKey('birth-year')), '1990');
await t.pumpAndSettle();
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
await tap( await tap(
t, t,
find.descendant( find.descendant(
@@ -92,10 +85,6 @@ void main() {
'PL', 'PL',
); );
await t.pumpAndSettle(); await t.pumpAndSettle();
}
Future<void> submit(WidgetTester t) async {
await profile(t);
await tap(t, find.byKey(const ValueKey('pick-passport'))); await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.byKey(const ValueKey('kyc-next'))); await tap(t, find.byKey(const ValueKey('kyc-next')));
await tap(t, find.byKey(const ValueKey('pick-selfie'))); await tap(t, find.byKey(const ValueKey('pick-selfie')));
@@ -104,41 +93,6 @@ void main() {
await tap(t, find.byKey(const ValueKey('kyc-submit'))); await tap(t, find.byKey(const ValueKey('kyc-submit')));
} }
testWidgets(
'invalid birthday blocks advancing and valid birthday survives Back',
(t) async {
await open(t);
await profile(t);
await tap(t, find.byKey(const ValueKey('pick-passport')));
final next = find.byKey(const ValueKey('kyc-next'));
await t.enterText(find.byKey(const ValueKey('birth-day')), '31');
await t.pumpAndSettle();
expect(t.widget<FilledButton>(next).onPressed, isNull);
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
await t.pumpAndSettle();
expect(t.widget<FilledButton>(next).onPressed, isNotNull);
await tap(t, next);
await tap(t, find.text('Back'));
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('birth-day')))
.controller!
.text,
'23',
);
expect(find.text('April'), findsOneWidget);
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('birth-year')))
.controller!
.text,
'1990',
);
},
);
testWidgets('two photos, review, pending and approval with service limits', ( testWidgets('two photos, review, pending and approval with service limits', (
t, t,
) async { ) async {
-229
View File
@@ -1,229 +0,0 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as images;
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/kyc/photo.dart';
import 'package:sxchnge_app/kyc/preview.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
Widget host(Widget child, {Locale locale = const Locale('en')}) => MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: SingleChildScrollView(child: child)),
);
// Start decoding outside FakeAsync as well as waiting outside it, including
// browser codec callbacks that otherwise remain pending in the test zone.
Future<void> show(WidgetTester tester, Widget widget) async {
await tester.runAsync(() => tester.pumpWidget(widget));
}
// pumpAndSettle alone does not wait for image codecs. Listen to the actual
// preview streams so a hidden errorBuilder cannot make this regression pass.
Future<void> decoded(WidgetTester tester, {bool succeeds = true}) async {
final providers = tester
.widgetList<Image>(find.byType(Image))
.map((image) => image.image)
.toList();
expect(providers, isNotEmpty);
for (final provider in providers) {
Object? renderError;
StackTrace? renderStack;
await tester.runAsync(() async {
final stream = provider.resolve(ImageConfiguration.empty);
final ready = Completer<void>();
final listener = ImageStreamListener(
(info, _) {
info.dispose();
if (!ready.isCompleted) ready.complete();
},
onError: (Object error, StackTrace? stack) {
renderError = error;
renderStack = stack;
if (!ready.isCompleted) ready.complete();
},
);
stream.addListener(listener);
try {
await ready.future.timeout(const Duration(seconds: 15));
} finally {
stream.removeListener(listener);
}
});
expect(
renderError,
succeeds ? isNull : isNotNull,
reason: succeeds ? '$renderError\n$renderStack' : null,
);
}
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
}
void main() {
late Uint8List png;
late Uint8List jpeg;
setUpAll(() {
// Synthetic, non-sensitive portrait and landscape photos in both formats.
png = images.encodePng(images.Image(width: 1200, height: 1600));
jpeg = images.encodeJpg(images.Image(width: 1600, height: 1200));
});
for (final format in ['PNG', 'JPEG']) {
testWidgets('accepted $format renders from an immutable photo model', (
tester,
) async {
final source = Uint8List.fromList(format == 'PNG' ? png : jpeg);
final photo = (await tester.runAsync(
() => FilePhotoService.validate(source),
))!;
final originalFirstByte = photo.bytes.first;
source[0] = 0;
expect(photo.bytes.first, originalFirstByte);
expect(() => photo.bytes[0] = 0, throwsUnsupportedError);
await show(
tester,
host(PhotoPreview(photo: photo, label: 'ID document photo')),
);
await decoded(tester);
final raw = tester.widget<RawImage>(find.byType(RawImage));
expect(raw.image, isNotNull);
expect(raw.image!.width, lessThanOrEqualTo(1000));
expect(
tester.widget<Image>(find.byType(Image)).semanticLabel,
'ID document photo',
);
expect(find.textContaining('damaged'), findsNothing);
expect(find.textContaining('could not be displayed'), findsNothing);
});
}
testWidgets('replacement and removal evict sensitive preview cache entries', (
tester,
) async {
final first = Photo(png);
await show(tester, host(PhotoPreview(photo: first, label: 'ID')));
await decoded(tester);
final initialProvider = tester.widget<Image>(find.byType(Image)).image;
final initialKey = await initialProvider.obtainKey(
ImageConfiguration.empty,
);
final cache = PaintingBinding.instance.imageCache;
expect(cache.statusForKey(initialKey).keepAlive, isTrue);
await show(tester, host(PhotoPreview(photo: first, label: 'Document')));
await decoded(tester);
expect(
identical(
tester.widget<Image>(find.byType(Image)).image,
initialProvider,
),
isTrue,
);
await show(tester, host(PhotoPreview(photo: Photo(jpeg), label: 'ID')));
await decoded(tester);
expect(cache.statusForKey(initialKey).untracked, isTrue);
final replacement = tester.widget<Image>(find.byType(Image)).image;
final replacementKey = await replacement.obtainKey(
ImageConfiguration.empty,
);
expect(cache.statusForKey(replacementKey).keepAlive, isTrue);
expect(tester.widget<RawImage>(find.byType(RawImage)).image!.width, 1000);
await show(tester, host(const SizedBox()));
await tester.pumpAndSettle();
expect(cache.statusForKey(replacementKey).untracked, isTrue);
});
testWidgets('ID and selfie both render on review and after returning', (
tester,
) async {
final passport = Photo(png);
final selfie = Photo(jpeg);
for (var visit = 0; visit < 2; visit++) {
await show(
tester,
host(
Column(
children: [
PhotoPanel(
kind: PhotoKind.passport,
photo: passport,
review: true,
),
PhotoPanel(kind: PhotoKind.selfie, photo: selfie, review: true),
],
),
),
);
await decoded(tester);
final previews = tester.widgetList<RawImage>(find.byType(RawImage));
expect(previews.length, 2);
expect(previews.every((image) => image.image != null), isTrue);
expect(find.text('Choose a different photo'), findsNothing);
await show(tester, host(const SizedBox()));
await tester.pumpAndSettle();
}
});
for (final locale in ['en', 'ru']) {
testWidgets(
'$locale preview failure explains recovery and can be replaced',
(tester) async {
tester.view.physicalSize = const Size(320, 900);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
var replacements = 0;
var removals = 0;
await show(
tester,
host(
PhotoPanel(
kind: PhotoKind.passport,
photo: Photo(Uint8List.fromList([1, 2, 3])),
onPick: () => replacements++,
onRemove: () => removals++,
),
locale: Locale(locale),
),
);
await decoded(tester, succeeds: false);
final l = AppLocalizations.of(tester.element(find.byType(PhotoPanel)))!;
expect(find.text(l.kycErrorPreview), findsOneWidget);
expect(find.text(l.kycErrorUnreadable), findsNothing);
expect(
tester.getSemantics(find.text(l.kycErrorPreview)),
matchesSemantics(isLiveRegion: true, label: l.kycErrorPreview),
);
final replace = find.byKey(const ValueKey('pick-passport'));
await tester.ensureVisible(replace);
await tester.tap(replace);
final remove = find.text(l.kycRemove);
await tester.ensureVisible(remove);
await tester.tap(remove);
expect(replacements, 1);
expect(removals, 1);
await show(
tester,
host(
PhotoPanel(kind: PhotoKind.passport, photo: Photo(jpeg)),
locale: Locale(locale),
),
);
await decoded(tester);
expect(find.text(l.kycErrorPreview), findsNothing);
expect(tester.widget<RawImage>(find.byType(RawImage)).image, isNotNull);
},
);
}
}
-38
View File
@@ -42,44 +42,6 @@ Future<GoRouter> openRoute(
} }
void main() { void main() {
testWidgets('queued signup receipt is transient and updates its recipient', (
tester,
) async {
final router = await openRoute(tester, '/');
await tester.pumpAndSettle();
router.goNamed(
AppRoutes.verificationSent,
extra: const VerificationDelivery(
email: 'first@example.com',
isSimulated: false,
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(find.text('first@example.com'), findsOneWidget);
router.goNamed(
AppRoutes.verificationSent,
extra: const VerificationDelivery(
email: 'second@example.com',
isSimulated: false,
),
);
await tester.pumpAndSettle();
expect(find.text('first@example.com'), findsNothing);
expect(find.text('second@example.com'), findsOneWidget);
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.state.toString(), isNot(contains('second@example.com')));
expect(restored.uri.toString(), '/verification-sent');
router.goNamed(AppRoutes.login);
await tester.pumpAndSettle();
router.goNamed(AppRoutes.verificationSent);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
expect(find.text('second@example.com'), findsNothing);
});
for (final language in ['en', 'ru']) { for (final language in ['en', 'ru']) {
testWidgets( testWidgets(
'verified link keeps success visible and prefills login ($language)', 'verified link keeps success visible and prefills login ($language)',
-153
View File
@@ -1,153 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
class DeliveryBackend extends BackendAuthServices {
DeliveryBackend(List<String> requests)
: super(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
requests.add(request.url.path);
return http.Response(
jsonEncode({
'data': request.url.path.endsWith('/signup')
? {'userId': 'customer'}
: {'accepted': true},
}),
202,
);
}),
);
int sends = 0;
bool failResend = true;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async {
sends++;
if (failResend) {
throw const VerificationException(VerificationError.internal);
}
return super.send(email: email, locale: locale);
}
}
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'accepted signup survives an unavailable resend service ($language)',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final requests = <String>[];
final backend = DeliveryBackend(requests);
final analytics = DummyAnalyticsService();
await tester.pumpWidget(
SxchngeApp(
backend: backend,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(language)),
),
);
await tester.pumpAndSettle();
final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up');
await tester.ensureVisible(signup);
await tester.tap(signup);
await tester.pumpAndSettle();
for (final entry in {
'name': 'Ada Lovelace',
'email': 'ada@example.com',
'password': 'password preserved',
'confirmation': 'password preserved',
}.entries) {
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
}
final submit = find.byKey(const ValueKey('submit'));
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(find.text('ada@example.com'), findsOneWidget);
expect(backend.sends, 0);
expect(requests, ['/api/v1/accounts/signup']);
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.uri.toString(), '/verification-sent');
expect(restored.state.toString(), isNot(contains('ada@example.com')));
expect(
analytics.events.where(
(event) => event.name == 'auth_signup_succeeded',
),
hasLength(1),
);
expect(
analytics.events.where(
(event) => event.name == 'auth_email_delivery_succeeded',
),
hasLength(1),
);
final resend = find.byKey(const ValueKey('verification-resend'));
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-error')),
findsOneWidget,
);
expect(backend.sends, 1);
expect(
tester
.widget<TextFormField>(
find.byKey(const ValueKey('verification-email')),
)
.controller!
.text,
'ada@example.com',
);
backend.failResend = false;
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(backend.sends, 2);
expect(requests.last, '/api/v1/accounts/verification/send');
expect(requests, hasLength(2));
expect(
analytics.events.map((event) => event.journeyId).toSet(),
hasLength(1),
);
expect(
analytics.events.map((event) => event.properties).toString(),
isNot(contains('ada@example.com')),
);
expect(tester.takeException(), isNull);
},
);
}
}
@@ -12,13 +12,9 @@ abstract final class BackendMapper {
return VerificationResult(isSimulated: false, verifiedEmail: dto.email); return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
} }
static AuthResult signup(BackendDTO dto, {required String email}) { static AuthResult signup(BackendDTO dto) {
if (dto.userId.isEmpty) throw const FormatException('Missing user'); if (dto.userId.isEmpty) throw const FormatException('Missing user');
return AuthResult( return AuthResult(userId: dto.userId, isSimulated: false);
userId: dto.userId,
isSimulated: false,
delivery: VerificationDelivery(email: email, isSimulated: false),
);
} }
static FactorChallenge challenge(BackendDTO dto, String id) { static FactorChallenge challenge(BackendDTO dto, String id) {
@@ -5,12 +5,6 @@ import '../models/verification.dart';
abstract final class AuthMapper { abstract final class AuthMapper {
static AuthResult fromDTO(AuthDTO dto) => static AuthResult fromDTO(AuthDTO dto) =>
AuthResult(userId: dto.userId, isSimulated: dto.simulated); AuthResult(userId: dto.userId, isSimulated: dto.simulated);
static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult(
userId: dto.userId,
isSimulated: dto.simulated,
delivery: VerificationDelivery(email: email, isSimulated: dto.simulated),
);
} }
abstract final class VerificationMapper { abstract final class VerificationMapper {
@@ -1,5 +1,4 @@
import 'two_factor.dart'; import 'two_factor.dart';
import 'verification.dart';
/// Ephemeral input: never persist, log, or include passwords in provider state. /// Ephemeral input: never persist, log, or include passwords in provider state.
class LoginRequest { class LoginRequest {
@@ -32,11 +31,9 @@ class AuthResult {
required this.userId, required this.userId,
required this.isSimulated, required this.isSimulated,
this.challenge, this.challenge,
this.delivery,
}); });
final String userId; final String userId;
final FactorChallenge? challenge; final FactorChallenge? challenge;
final VerificationDelivery? delivery;
final bool isSimulated; final bool isSimulated;
} }
@@ -11,13 +11,6 @@ enum VerificationStatus {
enum VerificationError { expired, invalid, internal, needsAssistance } enum VerificationError { expired, invalid, internal, needsAssistance }
/// An accepted email-queue operation, not proof of inbox delivery or activation.
class VerificationDelivery {
const VerificationDelivery({required this.email, required this.isSimulated});
final String email;
final bool isSimulated;
}
class VerificationException implements Exception { class VerificationException implements Exception {
const VerificationException(this.code); const VerificationException(this.code);
final VerificationError code; final VerificationError code;
@@ -4,22 +4,13 @@ import '../models/verification.dart';
import '../services/verification.dart'; import '../services/verification.dart';
class VerificationProvider extends ChangeNotifier { class VerificationProvider extends ChangeNotifier {
VerificationProvider(this.service, {VerificationDelivery? delivery}) VerificationProvider(this.service);
: _state = _initialState(delivery);
final VerificationService service; final VerificationService service;
VerificationState _state; VerificationState _state = const VerificationState(VerificationStatus.idle);
VerificationState get state => _state; VerificationState get state => _state;
bool _disposed = false; bool _disposed = false;
int _generation = 0; int _generation = 0;
static VerificationState _initialState(VerificationDelivery? delivery) =>
delivery == null
? const VerificationState(VerificationStatus.idle)
: VerificationState(
VerificationStatus.sent,
isSimulated: delivery.isSimulated,
);
Future<void> send({required String email, required String locale}) => _run( Future<void> send({required String email, required String locale}) => _run(
() => service.send(email: email.trim(), locale: locale), () => service.send(email: email.trim(), locale: locale),
VerificationStatus.sending, VerificationStatus.sending,
@@ -70,10 +61,10 @@ class VerificationProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void reset({VerificationDelivery? delivery}) { void reset() {
if (_disposed) return; if (_disposed) return;
++_generation; ++_generation;
_state = _initialState(delivery); _state = const VerificationState(VerificationStatus.idle);
notifyListeners(); notifyListeners();
} }
@@ -51,6 +51,7 @@ class BackendAuthServices
final _pending = <String, ({String token, String challengeId})>{}; final _pending = <String, ({String token, String challengeId})>{};
final _grants = <String, AuthSession>{}; final _grants = <String, AuthSession>{};
final _retryKeys = <String, String>{}; final _retryKeys = <String, String>{};
String? _queuedSignup;
AuthSession? _session; AuthSession? _session;
AuthSession? get session => _session; AuthSession? get session => _session;
@override @override
@@ -112,8 +113,9 @@ class BackendAuthServices
'locale': r.locale, 'locale': r.locale,
'idempotencyKey': key, 'idempotencyKey': key,
}); });
final result = BackendMapper.signup(dto, email: email); final result = BackendMapper.signup(dto);
_retryKeys.remove('signup:$email'); _retryKeys.remove('signup:$email');
_queuedSignup = email;
return result; return result;
} }
@@ -139,6 +141,14 @@ class BackendAuthServices
required String locale, required String locale,
}) async { }) async {
final normalized = email.trim().toLowerCase(); final normalized = email.trim().toLowerCase();
// Signup already queued this email. The existing confirmation page requests
// its initial delivery on mount; consume that receipt once.
if (_queuedSignup == normalized) {
_queuedSignup = null;
return VerificationMapper.fromDTO(
const VerificationDTO(simulated: false),
);
}
final key = _retryKeys.putIfAbsent('activation:$normalized', _key); final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
try { try {
await _post('verification/send', { await _post('verification/send', {
@@ -40,9 +40,8 @@ class DummySignupService implements SignupService, SimulationInfo {
Future<AuthResult> signup(SignupRequest request) async { Future<AuthResult> signup(SignupRequest request) async {
await Future<void>.delayed(delay); await Future<void>.delayed(delay);
if (failure case final error?) throw AuthException(error); if (failure case final error?) throw AuthException(error);
return AuthMapper.signup( return AuthMapper.fromDTO(
const AuthDTO(userId: 'demo-user', simulated: true), const AuthDTO(userId: 'demo-user', simulated: true),
email: request.email.trim(),
); );
} }
} }
@@ -111,27 +111,13 @@ class InstrumentedAuthServices
simulation: _simulation(loginService), simulation: _simulation(loginService),
); );
@override @override
Future<AuthResult> signup(SignupRequest request) async { Future<AuthResult> signup(SignupRequest request) => _run(
final result = await _run(
JourneyKind.signup, JourneyKind.signup,
JourneyStep.signup, JourneyStep.signup,
() => signupService.signup(request), () => signupService.signup(request),
(r) => r.isSimulated, (r) => r.isSimulated,
simulation: _simulation(signupService), simulation: _simulation(signupService),
); );
final delivery = result.delivery;
if (delivery != null) {
final operation = recorder.begin(
JourneyKind.verification,
JourneyStep.emailDelivery,
onboarding: true,
simulated: delivery.isSimulated,
);
recorder.finish(operation, simulated: delivery.isSimulated);
}
return result;
}
@override @override
Future<VerificationResult> send({ Future<VerificationResult> send({
required String email, required String email,
@@ -142,11 +142,11 @@ void main() {
.isSimulated, .isSimulated,
isTrue, isTrue,
); );
final accepted = await const DummySignupService(delay: Duration.zero) expect(
.signup(signup); (await const DummySignupService(delay: Duration.zero).signup(signup))
expect(accepted.isSimulated, isTrue); .isSimulated,
expect(accepted.delivery?.email, 'a@example.com'); isTrue,
expect(accepted.delivery?.isSimulated, isTrue); );
await expectLater( await expectLater(
const DummyLoginService( const DummyLoginService(
delay: Duration.zero, delay: Duration.zero,
@@ -89,9 +89,7 @@ void main() {
expect(keys[0], keys[1]); expect(keys[0], keys[1]);
}, },
); );
test( test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
'signup returns its receipt and every explicit resend reaches API',
() async {
final paths = <String>[]; final paths = <String>[];
final service = BackendAuthServices( final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'), baseUri: Uri.parse('https://api.example/api/v1'),
@@ -99,10 +97,7 @@ void main() {
client: MockClient((request) async { client: MockClient((request) async {
paths.add(request.url.path); paths.add(request.url.path);
if (request.url.path.endsWith('/signup')) { if (request.url.path.endsWith('/signup')) {
expect( expect(jsonDecode(request.body)['password'], ' password preserved ');
jsonDecode(request.body)['password'],
' password preserved ',
);
return response({'userId': 'user'}); return response({'userId': 'user'});
} }
return response({'accepted': true}); return response({'accepted': true});
@@ -118,19 +113,11 @@ void main() {
), ),
); );
expect(result.isSimulated, isFalse); expect(result.isSimulated, isFalse);
expect(result.delivery?.email, 'user@example.com'); await service.send(email: 'user@example.com', locale: 'en');
expect(result.delivery?.isSimulated, isFalse);
expect(paths, ['/api/v1/accounts/signup']); expect(paths, ['/api/v1/accounts/signup']);
await service.send(email: 'user@example.com', locale: 'en'); await service.send(email: 'user@example.com', locale: 'en');
expect(paths, [
'/api/v1/accounts/signup',
'/api/v1/accounts/verification/send',
]);
await service.send(email: 'user@example.com', locale: 'en');
expect(paths.last, '/api/v1/accounts/verification/send'); expect(paths.last, '/api/v1/accounts/verification/send');
expect(paths, hasLength(3)); });
},
);
test('login confirms backend session only after factor and consumes local grant once', () async { test('login confirms backend session only after factor and consumes local grant once', () async {
final now = DateTime.now(); final now = DateTime.now();
var verified = false; var verified = false;
@@ -28,36 +28,6 @@ class PendingService implements VerificationService {
} }
void main() { void main() {
test(
'accepted delivery initializes sent state without calling the service',
() async {
final service = PendingService();
final provider = VerificationProvider(
service,
delivery: const VerificationDelivery(
email: 'ada@example.com',
isSimulated: false,
),
);
addTearDown(provider.dispose);
expect(provider.state.status, VerificationStatus.sent);
expect(provider.state.isSimulated, isFalse);
expect(provider.state.result, isNull);
expect(service.calls, 0);
final resend = provider.send(email: 'ada@example.com', locale: 'en');
expect(provider.state.status, VerificationStatus.sending);
expect(service.calls, 1);
service.pending.completeError(
const VerificationException(VerificationError.internal),
);
await resend;
expect(provider.state.status, VerificationStatus.error);
expect(provider.state.result, isNull);
provider.reset();
expect(provider.state.status, VerificationStatus.idle);
},
);
for (final scenario in { for (final scenario in {
'demo-valid': VerificationStatus.verified, 'demo-valid': VerificationStatus.verified,
'demo-expired': VerificationStatus.expired, 'demo-expired': VerificationStatus.expired,