some DepositPage unit test

This commit is contained in:
Sebastian 2022-03-23 17:50:06 -03:00
parent d881f4fd25
commit cc18751e72
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
6 changed files with 250 additions and 105 deletions

View File

@ -25,7 +25,7 @@ function fromDir(startPath, regex) {
} }
const tests = fromDir('./src', /.test.ts$/) const tests = fromDir('./src', /.test.ts$/)
.filter(t => t === 'src/wallet/CreateManualWithdraw.test.ts') // .filter(t => t === 'src/wallet/DepositPage.test.ts')
.map(test => ({ .map(test => ({
input: test, input: test,
output: { output: {

View File

@ -40,8 +40,7 @@ describe("CreateManualWithdraw states", () => {
); );
if (!result.current) { if (!result.current) {
expect(result.current).not.to.be.undefined; expect.fail("hook didn't render");
return;
} }
expect(result.current.noExchangeFound).equal(true) expect(result.current.noExchangeFound).equal(true)
@ -53,8 +52,7 @@ describe("CreateManualWithdraw states", () => {
); );
if (!result.current) { if (!result.current) {
expect(result.current).not.to.be.undefined; expect.fail("hook didn't render");
return;
} }
expect(result.current.noExchangeFound).equal(true) expect(result.current.noExchangeFound).equal(true)
@ -67,8 +65,7 @@ describe("CreateManualWithdraw states", () => {
); );
if (!result.current) { if (!result.current) {
expect(result.current).not.to.be.undefined; expect.fail("hook didn't render");
return;
} }
expect(result.current.exchange.value).equal("url1") expect(result.current.exchange.value).equal("url1")
@ -80,8 +77,7 @@ describe("CreateManualWithdraw states", () => {
); );
if (!result.current) { if (!result.current) {
expect(result.current).not.to.be.undefined; expect.fail("hook didn't render");
return;
} }
expect(result.current.exchange.value).equal("url2") expect(result.current.exchange.value).equal("url2")

View File

@ -55,10 +55,12 @@ export interface State {
export interface TextFieldHandler { export interface TextFieldHandler {
onInput: (value: string) => void; onInput: (value: string) => void;
value: string; value: string;
error?: string;
} }
export interface SelectFieldHandler { export interface SelectFieldHandler {
onChange: (value: string) => void; onChange: (value: string) => void;
error?: string;
value: string; value: string;
list: Record<string, string>; list: Record<string, string>;
} }

View File

@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { Amounts, parsePaytoUri } from "@gnu-taler/taler-util"; import { Amounts, Balance, parsePaytoUri } from "@gnu-taler/taler-util";
import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits";
import { createExample } from "../test-utils"; import { createExample } from "../test-utils";
import { View as TestedComponent } from "./DepositPage"; import { View as TestedComponent } from "./DepositPage";
@ -40,13 +40,21 @@ async function alwaysReturnFeeToOne(): Promise<DepositFee> {
} }
export const WithEmptyAccountList = createExample(TestedComponent, { export const WithEmptyAccountList = createExample(TestedComponent, {
knownBankAccounts: [], accounts: [],
balance: Amounts.parseOrThrow("USD:10"), balances: [
{
available: "USD:10",
} as Balance,
],
onCalculateFee: alwaysReturnFeeToOne, onCalculateFee: alwaysReturnFeeToOne,
}); });
export const WithSomeBankAccounts = createExample(TestedComponent, { export const WithSomeBankAccounts = createExample(TestedComponent, {
knownBankAccounts: [parsePaytoUri("payto://iban/ES8877998399652238")!], accounts: [parsePaytoUri("payto://iban/ES8877998399652238")!],
balance: Amounts.parseOrThrow("EUR:10"), balances: [
{
available: "USD:10",
} as Balance,
],
onCalculateFee: alwaysReturnFeeToOne, onCalculateFee: alwaysReturnFeeToOne,
}); });

View File

@ -0,0 +1,63 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { useComponentState } from "./DepositPage";
import { expect } from "chai";
import { mountHook } from "../test-utils";
import { Amounts, Balance } from "@gnu-taler/taler-util";
const currency = "EUR"
const feeCalculator = async () => ({
coin: Amounts.parseOrThrow(`${currency}:1`),
wire: Amounts.parseOrThrow(`${currency}:1`),
refresh: Amounts.parseOrThrow(`${currency}:1`)
})
const someBalance = [{
available: 'EUR:10'
} as Balance]
describe("DepositPage states", () => {
it("should have status 'no-balance' when balance is empty", () => {
const { result } = mountHook(() =>
useComponentState(currency, [], [], feeCalculator),
);
if (!result.current) {
expect.fail("hook didn't render");
}
expect(result.current.status).equal("no-balance")
});
it("should have status 'no-accounts' when balance is not empty and accounts is empty", () => {
const { result } = mountHook(() =>
useComponentState(currency, [], someBalance, feeCalculator),
);
if (!result.current) {
expect.fail("hook didn't render");
}
expect(result.current.status).equal("no-accounts")
});
});

View File

@ -18,12 +18,15 @@ import {
AmountJson, AmountJson,
Amounts, Amounts,
AmountString, AmountString,
Balance,
PaytoUri, PaytoUri,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits";
import { saturate } from "polished";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Loading } from "../components/Loading"; import { Loading } from "../components/Loading";
import { LoadingError } from "../components/LoadingError";
import { SelectList } from "../components/SelectList"; import { SelectList } from "../components/SelectList";
import { import {
Button, Button,
@ -37,6 +40,7 @@ import {
import { useTranslationContext } from "../context/translation"; import { useTranslationContext } from "../context/translation";
import { useAsyncAsHook } from "../hooks/useAsyncAsHook"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook";
import * as wxApi from "../wxApi"; import * as wxApi from "../wxApi";
import { SelectFieldHandler, TextFieldHandler } from "./CreateManualWithdraw";
interface Props { interface Props {
currency: string; currency: string;
@ -45,45 +49,46 @@ interface Props {
} }
export function DepositPage({ currency, onCancel, onSuccess }: Props): VNode { export function DepositPage({ currency, onCancel, onSuccess }: Props): VNode {
const state = useAsyncAsHook(async () => { const state = useAsyncAsHook(async () => {
const balance = await wxApi.getBalance(); const { balances } = await wxApi.getBalance();
const bs = balance.balances.filter((b) => b.available.startsWith(currency)); const { accounts } = await wxApi.listKnownBankAccounts(currency);
const currencyBalance = return { accounts, balances };
bs.length === 0
? Amounts.getZero(currency)
: Amounts.parseOrThrow(bs[0].available);
const knownAccounts = await wxApi.listKnownBankAccounts(currency);
return { accounts: knownAccounts.accounts, currencyBalance };
}); });
const accounts = const { i18n } = useTranslationContext();
state === undefined ? [] : state.hasError ? [] : state.response.accounts;
const currencyBalance = async function doSend(p: PaytoUri, a: AmountJson): Promise<void> {
state === undefined const account = `payto://${p.targetType}/${p.targetPath}`;
? Amounts.getZero(currency) const amount = Amounts.stringify(a);
: state.hasError
? Amounts.getZero(currency)
: state.response.currencyBalance;
async function doSend(account: string, amount: AmountString): Promise<void> {
await wxApi.createDepositGroup(account, amount); await wxApi.createDepositGroup(account, amount);
onSuccess(currency); onSuccess(currency);
} }
async function getFeeForAmount( async function getFeeForAmount(
account: string, p: PaytoUri,
amount: AmountString, a: AmountJson,
): Promise<DepositFee> { ): Promise<DepositFee> {
const account = `payto://${p.targetType}/${p.targetPath}`;
const amount = Amounts.stringify(a);
return await wxApi.getFeeForDeposit(account, amount); return await wxApi.getFeeForDeposit(account, amount);
} }
if (accounts.length === 0) return <Loading />; if (state === undefined) return <Loading />;
if (state.hasError) {
return (
<LoadingError
title={<i18n.Translate>Could not load deposit balance</i18n.Translate>}
error={state}
/>
);
}
return ( return (
<View <View
onCancel={onCancel} onCancel={() => onCancel(currency)}
knownBankAccounts={accounts} currency={currency}
balance={currencyBalance} accounts={state.response.accounts}
balances={state.response.balances}
onSend={doSend} onSend={doSend}
onCalculateFee={getFeeForAmount} onCalculateFee={getFeeForAmount}
/> />
@ -91,25 +96,46 @@ export function DepositPage({ currency, onCancel, onSuccess }: Props): VNode {
} }
interface ViewProps { interface ViewProps {
knownBankAccounts: Array<PaytoUri>; accounts: Array<PaytoUri>;
balance: AmountJson; currency: string;
onCancel: (currency: string) => void; balances: Balance[];
onSend: (account: string, amount: AmountString) => Promise<void>; onCancel: () => void;
onSend: (account: PaytoUri, amount: AmountJson) => Promise<void>;
onCalculateFee: ( onCalculateFee: (
account: string, account: PaytoUri,
amount: AmountString, amount: AmountJson,
) => Promise<DepositFee>; ) => Promise<DepositFee>;
} }
export function View({ type State = NoBalanceState | NoAccountsState | DepositState;
onCancel,
knownBankAccounts, interface NoBalanceState {
balance, status: "no-balance";
onSend, }
onCalculateFee, interface NoAccountsState {
}: ViewProps): VNode { status: "no-accounts";
const { i18n } = useTranslationContext(); }
const accountMap = createLabelsForBankAccount(knownBankAccounts); interface DepositState {
status: "deposit";
amount: TextFieldHandler;
account: SelectFieldHandler;
totalFee: AmountJson;
totalToDeposit: AmountJson;
unableToDeposit: boolean;
selectedAccount: PaytoUri;
parsedAmount: AmountJson | undefined;
}
export function useComponentState(
currency: string,
accounts: PaytoUri[],
balances: Balance[],
onCalculateFee: (
account: PaytoUri,
amount: AmountJson,
) => Promise<DepositFee>,
): State {
const accountMap = createLabelsForBankAccount(accounts);
const [accountIdx, setAccountIdx] = useState(0); const [accountIdx, setAccountIdx] = useState(0);
const [amount, setAmount] = useState<number | undefined>(undefined); const [amount, setAmount] = useState<number | undefined>(undefined);
const [fee, setFee] = useState<DepositFee | undefined>(undefined); const [fee, setFee] = useState<DepositFee | undefined>(undefined);
@ -117,35 +143,108 @@ export function View({
setAmount(num); setAmount(num);
setFee(undefined); setFee(undefined);
} }
const currency = balance.currency;
const amountStr: AmountString = `${currency}:${amount}`; const selectedAmountSTR: AmountString = `${currency}:${amount}`;
const feeSum = const totalFee =
fee !== undefined fee !== undefined
? Amounts.sum([fee.wire, fee.coin, fee.refresh]).amount ? Amounts.sum([fee.wire, fee.coin, fee.refresh]).amount
: Amounts.getZero(currency); : Amounts.getZero(currency);
const account = knownBankAccounts.length const selectedAccount = accounts.length ? accounts[accountIdx] : undefined;
? knownBankAccounts[accountIdx]
: undefined; const parsedAmount =
const accountURI = !account amount === undefined ? undefined : Amounts.parse(selectedAmountSTR);
? ""
: `payto://${account.targetType}/${account.targetPath}`;
useEffect(() => { useEffect(() => {
if (amount === undefined) return; if (selectedAccount === undefined || parsedAmount === undefined) return;
onCalculateFee(accountURI, amountStr).then((result) => { onCalculateFee(selectedAccount, parsedAmount).then((result) => {
setFee(result); setFee(result);
}); });
}, [amount]); }, [amount]);
if (!balance) { const bs = balances.filter((b) => b.available.startsWith(currency));
const balance =
bs.length > 0
? Amounts.parseOrThrow(bs[0].available)
: Amounts.getZero(currency);
const isDirty = amount !== 0;
const amountError = !isDirty
? undefined
: !parsedAmount
? "Invalid amount"
: Amounts.cmp(balance, parsedAmount) === -1
? `Too much, your current balance is ${Amounts.stringifyValue(balance)}`
: undefined;
const totalToDeposit = parsedAmount
? Amounts.sub(parsedAmount, totalFee).amount
: Amounts.getZero(currency);
const unableToDeposit =
Amounts.isZero(totalToDeposit) ||
fee === undefined ||
amountError !== undefined;
if (Amounts.isZero(balance)) {
return {
status: "no-balance",
};
}
if (!accounts || !accounts.length || !selectedAccount) {
return {
status: "no-accounts",
};
}
return {
status: "deposit",
amount: {
value: String(amount),
onInput: (e) => {
const num = parseFloat(e);
if (!Number.isNaN(num)) {
updateAmount(num);
} else {
updateAmount(undefined);
setFee(undefined);
}
},
error: amountError,
},
account: {
list: accountMap,
value: String(accountIdx),
onChange: (s) => setAccountIdx(parseInt(s, 10)),
},
totalFee,
totalToDeposit,
unableToDeposit,
selectedAccount,
parsedAmount,
};
}
export function View({
onCancel,
currency,
accounts,
balances,
onSend,
onCalculateFee,
}: ViewProps): VNode {
const { i18n } = useTranslationContext();
const state = useComponentState(currency, accounts, balances, onCalculateFee);
if (state.status === "no-balance") {
return ( return (
<div> <div>
<i18n.Translate>no balance</i18n.Translate> <i18n.Translate>no balance</i18n.Translate>
</div> </div>
); );
} }
if (!knownBankAccounts || !knownBankAccounts.length) { if (state.status === "no-accounts") {
return ( return (
<Fragment> <Fragment>
<WarningBox> <WarningBox>
@ -159,30 +258,13 @@ export function View({
</ButtonBoxWarning> </ButtonBoxWarning>
</WarningBox> </WarningBox>
<footer> <footer>
<Button onClick={() => onCancel(currency)}> <Button onClick={onCancel}>
<i18n.Translate>Cancel</i18n.Translate> <i18n.Translate>Cancel</i18n.Translate>
</Button> </Button>
</footer> </footer>
</Fragment> </Fragment>
); );
} }
const parsedAmount =
amount === undefined ? undefined : Amounts.parse(amountStr);
const isDirty = amount !== 0;
const error = !isDirty
? undefined
: !parsedAmount
? "Invalid amount"
: Amounts.cmp(balance, parsedAmount) === -1
? `Too much, your current balance is ${Amounts.stringifyValue(balance)}`
: undefined;
const totalToDeposit = parsedAmount
? Amounts.sub(parsedAmount, feeSum).amount
: Amounts.getZero(currency);
const unableToDeposit =
Amounts.isZero(totalToDeposit) || fee === undefined || error !== undefined;
return ( return (
<Fragment> <Fragment>
@ -193,13 +275,13 @@ export function View({
<Input> <Input>
<SelectList <SelectList
label={<i18n.Translate>Bank account IBAN number</i18n.Translate>} label={<i18n.Translate>Bank account IBAN number</i18n.Translate>}
list={accountMap} list={state.account.list}
name="account" name="account"
value={String(accountIdx)} value={state.account.value}
onChange={(s) => setAccountIdx(parseInt(s, 10))} onChange={state.account.onChange}
/> />
</Input> </Input>
<InputWithLabel invalid={!!error}> <InputWithLabel invalid={!!state.amount.error}>
<label> <label>
<i18n.Translate>Amount</i18n.Translate> <i18n.Translate>Amount</i18n.Translate>
</label> </label>
@ -207,19 +289,11 @@ export function View({
<span>{currency}</span> <span>{currency}</span>
<input <input
type="number" type="number"
value={amount} value={state.amount.value}
onInput={(e) => { onInput={(e) => state.amount.onInput(e.currentTarget.value)}
const num = parseFloat(e.currentTarget.value);
if (!Number.isNaN(num)) {
updateAmount(num);
} else {
updateAmount(undefined);
setFee(undefined);
}
}}
/> />
</div> </div>
{error && <ErrorText>{error}</ErrorText>} {state.amount.error && <ErrorText>{state.amount.error}</ErrorText>}
</InputWithLabel> </InputWithLabel>
{ {
<Fragment> <Fragment>
@ -232,7 +306,7 @@ export function View({
<input <input
type="number" type="number"
disabled disabled
value={Amounts.stringifyValue(feeSum)} value={Amounts.stringifyValue(state.totalFee)}
/> />
</div> </div>
</InputWithLabel> </InputWithLabel>
@ -246,7 +320,7 @@ export function View({
<input <input
type="number" type="number"
disabled disabled
value={Amounts.stringifyValue(totalToDeposit)} value={Amounts.stringifyValue(state.totalToDeposit)}
/> />
</div> </div>
</InputWithLabel> </InputWithLabel>
@ -254,17 +328,19 @@ export function View({
} }
</section> </section>
<footer> <footer>
<Button onClick={() => onCancel(currency)}> <Button onClick={onCancel}>
<i18n.Translate>Cancel</i18n.Translate> <i18n.Translate>Cancel</i18n.Translate>
</Button> </Button>
{unableToDeposit ? ( {state.unableToDeposit ? (
<ButtonPrimary disabled> <ButtonPrimary disabled>
<i18n.Translate>Deposit</i18n.Translate> <i18n.Translate>Deposit</i18n.Translate>
</ButtonPrimary> </ButtonPrimary>
) : ( ) : (
<ButtonPrimary onClick={() => onSend(accountURI, amountStr)}> <ButtonPrimary
onClick={() => onSend(state.selectedAccount, state.parsedAmount!)}
>
<i18n.Translate> <i18n.Translate>
Deposit {Amounts.stringifyValue(totalToDeposit)} {currency} Deposit {Amounts.stringifyValue(state.totalToDeposit)} {currency}
</i18n.Translate> </i18n.Translate>
</ButtonPrimary> </ButtonPrimary>
)} )}