payments test case

This commit is contained in:
Sebastian 2022-04-21 14:23:53 -03:00
parent 8e29f91a56
commit 64acf8e2b1
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
8 changed files with 1154 additions and 391 deletions

View File

@ -19,9 +19,13 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { ContractTerms, PreparePayResultType } from "@gnu-taler/taler-util";
import {
Amounts,
ContractTerms,
PreparePayResultType,
} from "@gnu-taler/taler-util";
import { createExample } from "../test-utils.js";
import { PaymentRequestView as TestedComponent } from "./Pay.js";
import { View as TestedComponent } from "./Pay.js";
export default {
title: "cta/pay",
@ -30,175 +34,323 @@ export default {
};
export const NoBalance = createExample(TestedComponent, {
payStatus: {
status: PreparePayResultType.InsufficientBalance,
noncePriv: "",
proposalId: "proposal1234",
contractTerms: {
merchant: {
name: "someone",
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: undefined,
payHandler: {
onClick: async () => {
null;
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
amountRaw: "USD:10",
},
totalFees: Amounts.parseOrThrow("USD:0"),
payResult: undefined,
uri: "",
payStatus: {
status: PreparePayResultType.InsufficientBalance,
noncePriv: "",
proposalId: "proposal1234",
contractTerms: {
merchant: {
name: "someone",
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
amountRaw: "USD:10",
},
},
goBack: () => null,
goToWalletManualWithdraw: () => null,
});
export const NoEnoughBalance = createExample(TestedComponent, {
payStatus: {
status: PreparePayResultType.InsufficientBalance,
noncePriv: "",
proposalId: "proposal1234",
contractTerms: {
merchant: {
name: "someone",
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
currency: "USD",
fraction: 40000000,
value: 9,
},
payHandler: {
onClick: async () => {
null;
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
amountRaw: "USD:10",
},
balance: {
currency: "USD",
fraction: 40000000,
value: 9,
},
totalFees: Amounts.parseOrThrow("USD:0"),
payResult: undefined,
uri: "",
payStatus: {
status: PreparePayResultType.InsufficientBalance,
noncePriv: "",
proposalId: "proposal1234",
contractTerms: {
merchant: {
name: "someone",
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
amountRaw: "USD:10",
},
},
goBack: () => null,
goToWalletManualWithdraw: () => null,
});
export const PaymentPossible = createExample(TestedComponent, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
amountEffective: "USD:10",
amountRaw: "USD:10",
noncePriv: "",
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
currency: "USD",
fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
},
amount: "USD:10",
summary: "some beers",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
},
totalFees: Amounts.parseOrThrow("USD:0"),
payResult: undefined,
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
amountEffective: "USD:10",
amountRaw: "USD:10",
noncePriv: "",
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
},
amount: "USD:10",
summary: "some beers",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
},
},
goBack: () => null,
goToWalletManualWithdraw: () => null,
});
export const PaymentPossibleWithFee = createExample(TestedComponent, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
amountEffective: "USD:10.20",
amountRaw: "USD:10",
noncePriv: "",
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
currency: "USD",
fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
},
amount: "USD:10",
summary: "some beers",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
payResult: undefined,
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
amountEffective: "USD:10.20",
amountRaw: "USD:10",
noncePriv: "",
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
},
amount: "USD:10",
summary: "some beers",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
},
},
goBack: () => null,
goToWalletManualWithdraw: () => null,
});
import beer from "../../static-dev/beer.png";
export const TicketWithAProductList = createExample(TestedComponent, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
amountEffective: "USD:10",
amountRaw: "USD:10",
noncePriv: "",
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
currency: "USD",
fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
},
amount: "USD:10",
products: [
{
description: "ten beers",
price: "USD:1",
quantity: 10,
image: beer,
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
payResult: undefined,
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
amountEffective: "USD:10.20",
amountRaw: "USD:10",
noncePriv: "",
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
},
{
description: "beer without image",
price: "USD:1",
quantity: 10,
},
{
description: "one brown beer",
price: "USD:2",
quantity: 1,
image: beer,
},
],
summary: "some beers",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
amount: "USD:10",
summary: "some beers",
products: [
{
description: "ten beers",
price: "USD:1",
quantity: 10,
image: beer,
},
{
description: "beer without image",
price: "USD:1",
quantity: 10,
},
{
description: "one brown beer",
price: "USD:2",
quantity: 1,
image: beer,
},
],
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
},
},
goBack: () => null,
goToWalletManualWithdraw: () => null,
});
export const AlreadyConfirmedByOther = createExample(TestedComponent, {
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
currency: "USD",
fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
paid: false,
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
payResult: undefined,
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
paid: false,
},
},
goBack: () => null,
goToWalletManualWithdraw: () => null,
});
export const AlreadyPaidWithoutFulfillment = createExample(TestedComponent, {
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
currency: "USD",
fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
paid: true,
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
payResult: undefined,
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
paid: true,
},
},
goBack: () => null,
goToWalletManualWithdraw: () => null,
});
export const AlreadyPaidWithFulfillment = createExample(TestedComponent, {
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
currency: "USD",
fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
},
fulfillment_message:
"congratulations! you are looking at the fulfillment message! ",
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
paid: true,
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
payResult: undefined,
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
},
fulfillment_message:
"congratulations! you are looking at the fulfillment message! ",
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
paid: true,
},
},
goBack: () => null,
goToWalletManualWithdraw: () => null,
});

View File

@ -0,0 +1,408 @@
/*
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 { AmountJson, Amounts, BalancesResponse, ConfirmPayResult, ConfirmPayResultType, NotificationType, PreparePayResult, PreparePayResultType } from "@gnu-taler/taler-util";
import { expect } from "chai";
import { mountHook } from "../test-utils.js";
import * as wxApi from "../wxApi.js";
import { useComponentState } from "./Pay.jsx";
const nullFunction: any = () => null;
type VoidFunction = () => void;
type Subs = {
[key in NotificationType]?: VoidFunction
}
class SubsHandler {
private subs: Subs = {};
constructor() {
this.saveSubscription = this.saveSubscription.bind(this);
}
saveSubscription(messageTypes: NotificationType[], callback: VoidFunction): VoidFunction {
messageTypes.forEach(m => {
this.subs[m] = callback;
})
return nullFunction;
}
notifyEvent(event: NotificationType): void {
const cb = this.subs[event];
if (cb === undefined) expect.fail(`Expected to have a subscription for ${event}`);
cb()
}
}
describe("Pay CTA states", () => {
it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState(undefined, {
onUpdateNotification: nullFunction,
} as Partial<typeof wxApi> as any)
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate()
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
if (hook === undefined) expect.fail()
expect(hook.hasError).true;
expect(hook.operational).false;
}
await assertNoPendingUpdate()
});
it("should response with no balance", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState('taller://pay', {
onUpdateNotification: nullFunction,
preparePay: async () => ({
amountRaw: 'USD:10',
status: PreparePayResultType.InsufficientBalance,
} as Partial<PreparePayResult>),
getBalance: async () => ({
balances: []
} as Partial<BalancesResponse>),
} as Partial<typeof wxApi> as any)
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).undefined;
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:10'))
expect(r.payHandler.onClick).undefined;
}
await assertNoPendingUpdate()
});
it("should not be able to pay if there is no enough balance", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState('taller://pay', {
onUpdateNotification: nullFunction,
preparePay: async () => ({
amountRaw: 'USD:10',
status: PreparePayResultType.InsufficientBalance,
} as Partial<PreparePayResult>),
getBalance: async () => ({
balances: [{
available: 'USD:5'
}]
} as Partial<BalancesResponse>),
} as Partial<typeof wxApi> as any)
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:5'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:10'))
expect(r.payHandler.onClick).undefined;
}
await assertNoPendingUpdate()
});
it("should be able to pay (without fee)", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState('taller://pay', {
onUpdateNotification: nullFunction,
preparePay: async () => ({
amountRaw: 'USD:10',
amountEffective: 'USD:10',
status: PreparePayResultType.PaymentPossible,
} as Partial<PreparePayResult>),
getBalance: async () => ({
balances: [{
available: 'USD:15'
}]
} as Partial<BalancesResponse>),
} as Partial<typeof wxApi> as any)
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:15'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:10'))
expect(r.totalFees).deep.equal(Amounts.parseOrThrow('USD:0'))
expect(r.payHandler.onClick).not.undefined;
}
await assertNoPendingUpdate()
});
it("should be able to pay (with fee)", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState('taller://pay', {
onUpdateNotification: nullFunction,
preparePay: async () => ({
amountRaw: 'USD:9',
amountEffective: 'USD:10',
status: PreparePayResultType.PaymentPossible,
} as Partial<PreparePayResult>),
getBalance: async () => ({
balances: [{
available: 'USD:15'
}]
} as Partial<BalancesResponse>),
} as Partial<typeof wxApi> as any)
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:15'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:9'))
expect(r.totalFees).deep.equal(Amounts.parseOrThrow('USD:1'))
expect(r.payHandler.onClick).not.undefined;
}
await assertNoPendingUpdate()
});
it("should get confirmation done after pay successfully", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState('taller://pay', {
onUpdateNotification: nullFunction,
preparePay: async () => ({
amountRaw: 'USD:9',
amountEffective: 'USD:10',
status: PreparePayResultType.PaymentPossible,
} as Partial<PreparePayResult>),
getBalance: async () => ({
balances: [{
available: 'USD:15'
}]
} as Partial<BalancesResponse>),
confirmPay: async () => ({
type: ConfirmPayResultType.Done,
contractTerms: {}
} as Partial<ConfirmPayResult>),
} as Partial<typeof wxApi> as any)
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:15'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:9'))
expect(r.totalFees).deep.equal(Amounts.parseOrThrow('USD:1'))
if (r.payHandler.onClick === undefined) expect.fail();
r.payHandler.onClick()
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'confirmed') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:15'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:9'))
expect(r.totalFees).deep.equal(Amounts.parseOrThrow('USD:1'))
if (r.payResult.type !== ConfirmPayResultType.Done) expect.fail();
expect(r.payResult.contractTerms).not.undefined;
expect(r.payHandler.onClick).undefined;
}
await assertNoPendingUpdate()
});
it("should not stay in ready state after pay with error", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState('taller://pay', {
onUpdateNotification: nullFunction,
preparePay: async () => ({
amountRaw: 'USD:9',
amountEffective: 'USD:10',
status: PreparePayResultType.PaymentPossible,
} as Partial<PreparePayResult>),
getBalance: async () => ({
balances: [{
available: 'USD:15'
}]
} as Partial<BalancesResponse>),
confirmPay: async () => ({
type: ConfirmPayResultType.Pending,
lastError: { code: 1 },
} as Partial<ConfirmPayResult>),
} as Partial<typeof wxApi> as any)
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:15'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:9'))
expect(r.totalFees).deep.equal(Amounts.parseOrThrow('USD:1'))
if (r.payHandler.onClick === undefined) expect.fail();
r.payHandler.onClick()
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:15'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:9'))
expect(r.totalFees).deep.equal(Amounts.parseOrThrow('USD:1'))
expect(r.payHandler.onClick).undefined;
if (r.payHandler.error === undefined) expect.fail();
//FIXME: error message here is bad
expect(r.payHandler.error.errorDetail.hint).eq("could not confirm payment")
expect(r.payHandler.error.errorDetail.payResult).deep.equal({
type: ConfirmPayResultType.Pending,
lastError: { code: 1 }
})
}
await assertNoPendingUpdate()
});
it("should update balance if a coins is withdraw", async () => {
const subscriptions = new SubsHandler();
let availableBalance = Amounts.parseOrThrow("USD:10");
function notifyCoinWithdrawn(newAmount: AmountJson): void {
availableBalance = Amounts.add(availableBalance, newAmount).amount
subscriptions.notifyEvent(NotificationType.CoinWithdrawn)
}
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState('taller://pay', {
onUpdateNotification: subscriptions.saveSubscription,
preparePay: async () => ({
amountRaw: 'USD:9',
amountEffective: 'USD:10',
status: PreparePayResultType.PaymentPossible,
} as Partial<PreparePayResult>),
getBalance: async () => ({
balances: [{
available: Amounts.stringify(availableBalance)
}]
} as Partial<BalancesResponse>),
} as Partial<typeof wxApi> as any)
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:10'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:9'))
expect(r.totalFees).deep.equal(Amounts.parseOrThrow('USD:1'))
expect(r.payHandler.onClick).not.undefined;
notifyCoinWithdrawn(Amounts.parseOrThrow("USD:5"));
}
await waitNextUpdate();
{
const r = getLastResultOrThrow()
if (r.status !== 'ready') expect.fail()
expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:15'));
expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:9'))
expect(r.totalFees).deep.equal(Amounts.parseOrThrow('USD:1'))
expect(r.payHandler.onClick).not.undefined;
}
await assertNoPendingUpdate()
});
});

View File

@ -27,9 +27,7 @@
import {
AmountJson,
AmountLike,
Amounts,
AmountString,
ConfirmPayResult,
ConfirmPayResultDone,
ConfirmPayResultType,
@ -38,12 +36,14 @@ import {
PreparePayResult,
PreparePayResultType,
Product,
TalerErrorCode,
} from "@gnu-taler/taler-util";
import { TalerError } from "@gnu-taler/taler-wallet-core";
import { Fragment, h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
import { Amount } from "../components/Amount.js";
import { ErrorMessage } from "../components/ErrorMessage.js";
import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js";
import { Loading } from "../components/Loading.js";
import { LoadingError } from "../components/LoadingError.js";
import { LogoHeader } from "../components/LogoHeader.js";
@ -60,7 +60,12 @@ import {
WarningBox,
} from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.js";
import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import {
HookError,
useAsyncAsHook,
useAsyncAsHook2,
} from "../hooks/useAsyncAsHook.js";
import { ButtonHandler } from "../wallet/CreateManualWithdraw.js";
import * as wxApi from "../wxApi.js";
interface Props {
@ -69,47 +74,88 @@ interface Props {
goBack: () => void;
}
const doPayment = async (
async function doPayment(
payStatus: PreparePayResult,
): Promise<ConfirmPayResultDone> => {
api: typeof wxApi,
): Promise<ConfirmPayResultDone> {
if (payStatus.status !== "payment-possible") {
throw Error(`invalid state: ${payStatus.status}`);
throw TalerError.fromUncheckedDetail({
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
hint: `payment is not possible: ${payStatus.status}`,
});
}
const proposalId = payStatus.proposalId;
const res = await wxApi.confirmPay(proposalId, undefined);
const res = await api.confirmPay(proposalId, undefined);
if (res.type !== ConfirmPayResultType.Done) {
throw Error("payment pending");
throw TalerError.fromUncheckedDetail({
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
hint: `could not confirm payment`,
payResult: res,
});
}
const fu = res.contractTerms.fulfillment_url;
if (fu) {
document.location.href = fu;
}
return res;
};
}
export function PayPage({
talerPayUri,
goToWalletManualWithdraw,
goBack,
}: Props): VNode {
const { i18n } = useTranslationContext();
type State = Loading | Ready | Confirmed;
interface Loading {
status: "loading";
hook: HookError | undefined;
}
interface Ready {
status: "ready";
hook: undefined;
uri: string;
amount: AmountJson;
totalFees: AmountJson;
payStatus: PreparePayResult;
balance: AmountJson | undefined;
payHandler: ButtonHandler;
payResult: undefined;
}
interface Confirmed {
status: "confirmed";
hook: undefined;
uri: string;
amount: AmountJson;
totalFees: AmountJson;
payStatus: PreparePayResult;
balance: AmountJson | undefined;
payResult: ConfirmPayResult;
payHandler: ButtonHandler;
}
export function useComponentState(
talerPayUri: string | undefined,
api: typeof wxApi,
): State {
const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>(
undefined,
);
const [payErrMsg, setPayErrMsg] = useState<TalerError | string | undefined>(
undefined,
);
const [payErrMsg, setPayErrMsg] = useState<TalerError | undefined>(undefined);
const hook = useAsyncAsHook(async () => {
if (!talerPayUri) throw Error("Missing pay uri");
const payStatus = await wxApi.preparePay(talerPayUri);
const balance = await wxApi.getBalance();
return { payStatus, balance };
}, [NotificationType.CoinWithdrawn]);
const hook = useAsyncAsHook2(async () => {
if (!talerPayUri) throw Error("ERROR_NO-URI-FOR-PAYMENT");
const payStatus = await api.preparePay(talerPayUri);
const balance = await api.getBalance();
return { payStatus, balance, uri: talerPayUri };
});
useEffect(() => {
const payStatus =
hook && !hook.hasError ? hook.response.payStatus : undefined;
api.onUpdateNotification([NotificationType.CoinWithdrawn], () => {
hook?.retry();
});
});
const hookResponse = !hook || hook.hasError ? undefined : hook.response;
useEffect(() => {
if (!hookResponse) return;
const { payStatus } = hookResponse;
if (
payStatus &&
payStatus.status === PreparePayResultType.AlreadyConfirmed &&
@ -122,74 +168,139 @@ export function PayPage({
}, 3000);
}
}
}, []);
}, [hookResponse]);
if (!hook) {
return <Loading />;
}
if (hook.hasError) {
return (
<LoadingError
title={<i18n.Translate>Could not load pay status</i18n.Translate>}
error={hook}
/>
);
if (!hook || hook.hasError) {
return {
status: "loading",
hook,
};
}
const { payStatus } = hook.response;
const amount = Amounts.parseOrThrow(payStatus.amountRaw);
const foundBalance = hook.response.balance.balances.find(
(b) =>
Amounts.parseOrThrow(b.available).currency ===
Amounts.parseOrThrow(hook.response.payStatus.amountRaw).currency,
(b) => Amounts.parseOrThrow(b.available).currency === amount.currency,
);
const foundAmount = foundBalance
? Amounts.parseOrThrow(foundBalance.available)
: undefined;
const onClick = async (): Promise<void> => {
async function doPayment(): Promise<void> {
try {
const res = await doPayment(hook.response.payStatus);
if (payStatus.status !== "payment-possible") {
throw TalerError.fromUncheckedDetail({
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
hint: `payment is not possible: ${payStatus.status}`,
});
}
const res = await api.confirmPay(payStatus.proposalId, undefined);
if (res.type !== ConfirmPayResultType.Done) {
throw TalerError.fromUncheckedDetail({
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
hint: `could not confirm payment`,
payResult: res,
});
}
const fu = res.contractTerms.fulfillment_url;
if (fu) {
if (typeof window !== "undefined") {
document.location.href = fu;
} else {
console.log(`should redirect to ${fu}`);
}
}
setPayResult(res);
} catch (e) {
console.error(e);
if (e instanceof Error) {
setPayErrMsg(e.message);
if (e instanceof TalerError) {
setPayErrMsg(e);
}
}
}
const payDisabled =
payErrMsg ||
!foundAmount ||
payStatus.status === PreparePayResultType.InsufficientBalance;
const payHandler: ButtonHandler = {
onClick: payDisabled ? undefined : doPayment,
error: payErrMsg,
};
let totalFees = Amounts.getZero(amount.currency);
if (payStatus.status === PreparePayResultType.PaymentPossible) {
const amountEffective: AmountJson = Amounts.parseOrThrow(
payStatus.amountEffective,
);
totalFees = Amounts.sub(amountEffective, amount).amount;
}
if (!payResult) {
return {
status: "ready",
hook: undefined,
uri: hook.response.uri,
amount,
totalFees,
balance: foundAmount,
payHandler,
payStatus: hook.response.payStatus,
payResult,
};
}
return {
status: "confirmed",
hook: undefined,
uri: hook.response.uri,
amount,
totalFees,
balance: foundAmount,
payStatus: hook.response.payStatus,
payResult,
payHandler: {},
};
}
export function PayPage({
talerPayUri,
goToWalletManualWithdraw,
goBack,
}: Props): VNode {
const { i18n } = useTranslationContext();
const state = useComponentState(talerPayUri, wxApi);
if (state.status === "loading") {
if (!state.hook) return <Loading />;
return (
<LoadingError
title={<i18n.Translate>Could not load pay status</i18n.Translate>}
error={state.hook}
/>
);
}
return (
<PaymentRequestView
uri={talerPayUri!}
payStatus={hook.response.payStatus}
payResult={payResult}
onClick={onClick}
<View
state={state}
goBack={goBack}
goToWalletManualWithdraw={goToWalletManualWithdraw}
balance={foundAmount}
/>
);
}
export interface PaymentRequestViewProps {
payStatus: PreparePayResult;
payResult?: ConfirmPayResult;
onClick: () => void;
payErrMsg?: string;
uri: string;
goToWalletManualWithdraw: (s: string) => void;
balance: AmountJson | undefined;
}
export function PaymentRequestView({
uri,
payStatus,
payResult,
onClick,
export function View({
state,
goBack,
goToWalletManualWithdraw,
balance,
}: PaymentRequestViewProps): VNode {
}: {
state: Ready | Confirmed;
goToWalletManualWithdraw: (currency?: string) => void;
goBack: () => void;
}): VNode {
const { i18n } = useTranslationContext();
let totalFees: AmountJson = Amounts.getZero(payStatus.amountRaw);
const contractTerms: ContractTerms = payStatus.contractTerms;
const contractTerms: ContractTerms = state.payStatus.contractTerms;
if (!contractTerms) {
return (
@ -203,124 +314,6 @@ export function PaymentRequestView({
);
}
const amountRaw = Amounts.parseOrThrow(payStatus.amountRaw);
if (payStatus.status === PreparePayResultType.PaymentPossible) {
const amountEffective: AmountJson = Amounts.parseOrThrow(
payStatus.amountEffective,
);
totalFees = Amounts.sub(amountEffective, amountRaw).amount;
}
function Alternative(): VNode {
const [showQR, setShowQR] = useState<boolean>(false);
const privateUri =
payStatus.status !== PreparePayResultType.AlreadyConfirmed
? `${uri}&n=${payStatus.noncePriv}`
: uri;
if (!uri) return <Fragment />;
return (
<section>
<LinkSuccess upperCased onClick={() => setShowQR((qr) => !qr)}>
{!showQR ? (
<i18n.Translate>Pay with a mobile phone</i18n.Translate>
) : (
<i18n.Translate>Hide QR</i18n.Translate>
)}
</LinkSuccess>
{showQR && (
<div>
<QR text={privateUri} />
<i18n.Translate>
Scan the QR code or
<a href={privateUri}>
<i18n.Translate>click here</i18n.Translate>
</a>
</i18n.Translate>
</div>
)}
</section>
);
}
function ButtonsSection(): VNode {
if (payResult) {
if (payResult.type === ConfirmPayResultType.Pending) {
return (
<section>
<div>
<p>
<i18n.Translate>Processing</i18n.Translate>...
</p>
</div>
</section>
);
}
return <Fragment />;
}
if (payStatus.status === PreparePayResultType.PaymentPossible) {
return (
<Fragment>
<section>
<ButtonSuccess upperCased onClick={onClick}>
<i18n.Translate>
Pay {<Amount value={payStatus.amountEffective} />}
</i18n.Translate>
</ButtonSuccess>
</section>
<Alternative />
</Fragment>
);
}
if (payStatus.status === PreparePayResultType.InsufficientBalance) {
return (
<Fragment>
<section>
{balance ? (
<WarningBox>
<i18n.Translate>
Your balance of {<Amount value={balance} />} is not enough to
pay for this purchase
</i18n.Translate>
</WarningBox>
) : (
<WarningBox>
<i18n.Translate>
Your balance is not enough to pay for this purchase.
</i18n.Translate>
</WarningBox>
)}
</section>
<section>
<ButtonSuccess
upperCased
onClick={() => goToWalletManualWithdraw(amountRaw.currency)}
>
<i18n.Translate>Withdraw digital cash</i18n.Translate>
</ButtonSuccess>
</section>
<Alternative />
</Fragment>
);
}
if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
return (
<Fragment>
<section>
{payStatus.paid && contractTerms.fulfillment_message && (
<Part
title={<i18n.Translate>Merchant message</i18n.Translate>}
text={contractTerms.fulfillment_message}
kind="neutral"
/>
)}
</section>
{!payStatus.paid && <Alternative />}
</Fragment>
);
}
return <span />;
}
return (
<WalletAction>
<LogoHeader />
@ -328,70 +321,31 @@ export function PaymentRequestView({
<SubTitle>
<i18n.Translate>Digital cash payment</i18n.Translate>
</SubTitle>
{payStatus.status === PreparePayResultType.AlreadyConfirmed &&
(payStatus.paid ? (
payStatus.contractTerms.fulfillment_url ? (
<SuccessBox>
<i18n.Translate>
Already paid, you are going to be redirected to{" "}
<a href={payStatus.contractTerms.fulfillment_url}>
{payStatus.contractTerms.fulfillment_url}
</a>
</i18n.Translate>
</SuccessBox>
) : (
<SuccessBox>
<i18n.Translate>Already paid</i18n.Translate>
</SuccessBox>
)
) : (
<WarningBox>
<i18n.Translate>Already claimed</i18n.Translate>
</WarningBox>
))}
{payResult && payResult.type === ConfirmPayResultType.Done && (
<SuccessBox>
<h3>
<i18n.Translate>Payment complete</i18n.Translate>
</h3>
<p>
{!payResult.contractTerms.fulfillment_message ? (
payResult.contractTerms.fulfillment_url ? (
<i18n.Translate>
You are going to be redirected to $
{payResult.contractTerms.fulfillment_url}
</i18n.Translate>
) : (
<i18n.Translate>You can close this page.</i18n.Translate>
)
) : (
payResult.contractTerms.fulfillment_message
)}
</p>
</SuccessBox>
)}
<ShowImportantMessage state={state} />
<section>
{payStatus.status !== PreparePayResultType.InsufficientBalance &&
Amounts.isNonZero(totalFees) && (
{state.payStatus.status !== PreparePayResultType.InsufficientBalance &&
Amounts.isNonZero(state.totalFees) && (
<Part
big
title={<i18n.Translate>Total to pay</i18n.Translate>}
text={<Amount value={payStatus.amountEffective} />}
text={<Amount value={state.payStatus.amountEffective} />}
kind="negative"
/>
)}
<Part
big
title={<i18n.Translate>Purchase amount</i18n.Translate>}
text={<Amount value={payStatus.amountRaw} />}
text={<Amount value={state.payStatus.amountRaw} />}
kind="neutral"
/>
{Amounts.isNonZero(totalFees) && (
{Amounts.isNonZero(state.totalFees) && (
<Fragment>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={totalFees} />}
text={<Amount value={state.totalFees} />}
kind="negative"
/>
</Fragment>
@ -417,9 +371,12 @@ export function PaymentRequestView({
<ProductList products={contractTerms.products} />
)}
</section>
<ButtonsSection />
<ButtonsSection
state={state}
goToWalletManualWithdraw={goToWalletManualWithdraw}
/>
<section>
<Link upperCased>
<Link upperCased onClick={goBack}>
<i18n.Translate>Cancel</i18n.Translate>
</Link>
</section>
@ -495,3 +452,189 @@ function ProductList({ products }: { products: Product[] }): VNode {
</Fragment>
);
}
function ShowImportantMessage({ state }: { state: Ready | Confirmed }): VNode {
const { i18n } = useTranslationContext();
const { payStatus } = state;
if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
if (payStatus.paid) {
if (payStatus.contractTerms.fulfillment_url) {
return (
<SuccessBox>
<i18n.Translate>
Already paid, you are going to be redirected to{" "}
<a href={payStatus.contractTerms.fulfillment_url}>
{payStatus.contractTerms.fulfillment_url}
</a>
</i18n.Translate>
</SuccessBox>
);
}
return (
<SuccessBox>
<i18n.Translate>Already paid</i18n.Translate>
</SuccessBox>
);
}
return (
<WarningBox>
<i18n.Translate>Already claimed</i18n.Translate>
</WarningBox>
);
}
if (state.status == "confirmed") {
const { payResult, payHandler } = state;
if (payHandler.error) {
return <ErrorTalerOperation error={payHandler.error.errorDetail} />;
}
if (payResult.type === ConfirmPayResultType.Done) {
return (
<SuccessBox>
<h3>
<i18n.Translate>Payment complete</i18n.Translate>
</h3>
<p>
{!payResult.contractTerms.fulfillment_message ? (
payResult.contractTerms.fulfillment_url ? (
<i18n.Translate>
You are going to be redirected to $
{payResult.contractTerms.fulfillment_url}
</i18n.Translate>
) : (
<i18n.Translate>You can close this page.</i18n.Translate>
)
) : (
payResult.contractTerms.fulfillment_message
)}
</p>
</SuccessBox>
);
}
}
return <Fragment />;
}
function PayWithMobile({ state }: { state: Ready }): VNode {
const { i18n } = useTranslationContext();
const [showQR, setShowQR] = useState<boolean>(false);
const privateUri =
state.payStatus.status !== PreparePayResultType.AlreadyConfirmed
? `${state.uri}&n=${state.payStatus.noncePriv}`
: state.uri;
return (
<section>
<LinkSuccess upperCased onClick={() => setShowQR((qr) => !qr)}>
{!showQR ? (
<i18n.Translate>Pay with a mobile phone</i18n.Translate>
) : (
<i18n.Translate>Hide QR</i18n.Translate>
)}
</LinkSuccess>
{showQR && (
<div>
<QR text={privateUri} />
<i18n.Translate>
Scan the QR code or
<a href={privateUri}>
<i18n.Translate>click here</i18n.Translate>
</a>
</i18n.Translate>
</div>
)}
</section>
);
}
function ButtonsSection({
state,
goToWalletManualWithdraw,
}: {
state: Ready | Confirmed;
goToWalletManualWithdraw: (currency: string) => void;
}): VNode {
const { i18n } = useTranslationContext();
if (state.status === "ready") {
const { payStatus } = state;
if (payStatus.status === PreparePayResultType.PaymentPossible) {
return (
<Fragment>
<section>
<ButtonSuccess upperCased onClick={state.payHandler.onClick}>
<i18n.Translate>
Pay {<Amount value={payStatus.amountEffective} />}
</i18n.Translate>
</ButtonSuccess>
</section>
<PayWithMobile state={state} />
</Fragment>
);
}
if (payStatus.status === PreparePayResultType.InsufficientBalance) {
return (
<Fragment>
<section>
{state.balance ? (
<WarningBox>
<i18n.Translate>
Your balance of {<Amount value={state.balance} />} is not
enough to pay for this purchase
</i18n.Translate>
</WarningBox>
) : (
<WarningBox>
<i18n.Translate>
Your balance is not enough to pay for this purchase.
</i18n.Translate>
</WarningBox>
)}
</section>
<section>
<ButtonSuccess
upperCased
onClick={() => goToWalletManualWithdraw(state.amount.currency)}
>
<i18n.Translate>Withdraw digital cash</i18n.Translate>
</ButtonSuccess>
</section>
<PayWithMobile state={state} />
</Fragment>
);
}
if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
return (
<Fragment>
<section>
{payStatus.paid &&
state.payStatus.contractTerms.fulfillment_message && (
<Part
title={<i18n.Translate>Merchant message</i18n.Translate>}
text={state.payStatus.contractTerms.fulfillment_message}
kind="neutral"
/>
)}
</section>
{!payStatus.paid && <PayWithMobile state={state} />}
</Fragment>
);
}
}
if (state.status === "confirmed") {
if (state.payResult.type === ConfirmPayResultType.Pending) {
return (
<section>
<div>
<p>
<i18n.Translate>Processing</i18n.Translate>...
</p>
</div>
</section>
);
}
}
return <Fragment />;
}

View File

@ -149,7 +149,7 @@ describe("Withdraw CTA states", () => {
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
expect(state.doWithdrawal.disabled).false
expect(state.doWithdrawal.onClick).not.undefined
expect(state.mustAcceptFirst).false
}
@ -213,7 +213,7 @@ describe("Withdraw CTA states", () => {
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
expect(state.doWithdrawal.disabled).true
expect(state.doWithdrawal.onClick).undefined
expect(state.mustAcceptFirst).true
// accept TOS
@ -238,7 +238,7 @@ describe("Withdraw CTA states", () => {
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
expect(state.doWithdrawal.disabled).false
expect(state.doWithdrawal.onClick).not.undefined
expect(state.mustAcceptFirst).true
}

View File

@ -119,7 +119,7 @@ export function useComponentState(
const uriHookDep =
!uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response
? undefined
: uriInfoHook;
: uriInfoHook.response;
const { amount, thisExchange, thisCurrencyExchanges } = useMemo(() => {
if (!uriHookDep)
@ -129,7 +129,7 @@ export function useComponentState(
thisCurrencyExchanges: [],
};
const { uriInfo, knownExchanges } = uriHookDep.response;
const { uriInfo, knownExchanges } = uriHookDep;
const amount = uriInfo ? Amounts.parseOrThrow(uriInfo.amount) : undefined;
const thisCurrencyExchanges =
@ -324,9 +324,11 @@ export function useComponentState(
withdrawalFee,
chosenAmount: amount,
doWithdrawal: {
onClick: doWithdrawAndCheckError,
onClick:
doingWithdraw || (mustAcceptFirst && !reviewed)
? undefined
: doWithdrawAndCheckError,
error: withdrawError,
disabled: doingWithdraw || (mustAcceptFirst && !reviewed),
},
tosProps: !termsState
? undefined
@ -427,7 +429,7 @@ export function View({ state }: { state: Success }): VNode {
(state.mustAcceptFirst && state.tosProps.reviewed)) && (
<ButtonSuccess
upperCased
disabled={state.doWithdrawal.disabled}
disabled={!state.doWithdrawal.onClick}
onClick={state.doWithdrawal.onClick}
>
<i18n.Translate>Confirm withdrawal</i18n.Translate>
@ -436,7 +438,7 @@ export function View({ state }: { state: Success }): VNode {
{state.tosProps.terms.status === "notfound" && (
<ButtonWarning
upperCased
disabled={state.doWithdrawal.disabled}
disabled={!state.doWithdrawal.onClick}
onClick={state.doWithdrawal.onClick}
>
<i18n.Translate>Withdraw anyway</i18n.Translate>

View File

@ -39,7 +39,12 @@ export interface HookOperationalError {
details: TalerErrorDetail;
}
interface WithRetry {
retry: () => void;
}
export type HookResponse<T> = HookOk<T> | HookError | undefined;
export type HookResponseWithRetry<T> = ((HookOk<T> | HookError) & WithRetry) | undefined;
export function useAsyncAsHook<T>(
fn: () => Promise<T | false>,
@ -84,3 +89,45 @@ export function useAsyncAsHook<T>(
}, [args]);
return result;
}
export function useAsyncAsHook2<T>(
fn: () => Promise<T | false>,
deps?: any[],
): HookResponseWithRetry<T> {
const [result, setHookResponse] = useState<HookResponse<T>>(undefined);
const args = useMemo(() => ({
fn
// eslint-disable-next-line react-hooks/exhaustive-deps
}), deps || [])
async function doAsync(): Promise<void> {
try {
const response = await args.fn();
if (response === false) return;
setHookResponse({ hasError: false, response });
} catch (e) {
if (e instanceof TalerError) {
setHookResponse({
hasError: true,
operational: true,
details: e.errorDetail,
});
} else if (e instanceof Error) {
setHookResponse({
hasError: true,
operational: false,
message: e.message,
});
}
}
}
useEffect(() => {
doAsync();
}, [args]);
if (!result) return undefined;
return { ...result, retry: doAsync };
}

View File

@ -64,7 +64,6 @@ export function renderNodeOrBrowser(Component: any, args: any): void {
interface Mounted<T> {
unmount: () => void;
getLastResult: () => T | null;
getLastResultOrThrow: () => T;
assertNoPendingUpdate: () => void;
waitNextUpdate: (s?: string) => Promise<void>;
@ -76,15 +75,23 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
// const result: { current: T | null } = {
// current: null
// }
let lastResult: T | null = null;
let lastResult: T | Error | null = null;
const listener: Array<() => void> = []
// component that's going to hold the hook
function Component(): VNode {
const hookResult = callback()
// save the hook result
lastResult = hookResult
try {
lastResult = callback()
} catch (e) {
if (e instanceof Error) {
lastResult = e
} else {
lastResult = new Error(`mounting the hook throw an exception: ${e}`)
}
}
// notify to everyone waiting for an update and clean the queue
listener.splice(0, listener.length).forEach(cb => cb())
return create(Fragment, {})
@ -123,7 +130,7 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
}
}
function getLastResult(): T | null {
function getLastResult(): T | Error | null {
const copy = lastResult
lastResult = null
return copy;
@ -131,6 +138,7 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
function getLastResultOrThrow(): T {
const r = getLastResult()
if (r instanceof Error) throw r;
if (!r) throw Error('there was no last result')
return r;
}
@ -143,14 +151,18 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
listener.push(() => {
clearTimeout(tid)
rej(Error(`Expecting no pending result but the hook get updated. Check the dependencies of the hooks.`))
rej(Error(`Expecting no pending result but the hook got updated.
If the update was not intended you need to check the hook dependencies
(or dependencies of the internal state) but otherwise make
sure to consume the result before ending the test.`))
})
})
const r = getLastResult()
if (r) throw Error('There are still pending results. This may happen because the hook did a new update but the test didn\'t get the result using getLastResult');
if (r) throw Error(`There are still pending results.
This may happen because the hook did a new update but the test didn't consume the result using getLastResult`);
}
return {
unmount, getLastResult, getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate
unmount, getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate
}
}

View File

@ -62,8 +62,7 @@ export interface TextFieldHandler {
}
export interface ButtonHandler {
onClick: () => Promise<void>;
disabled?: boolean;
onClick?: () => Promise<void>;
error?: TalerError;
}