merchant-backoffice

ZZZ: Inactive/Deprecated
Log | Files | Refs | Submodules | README

commit aa05541c627b9fb5ee066980292805e6ee84da34
parent 8f63e8317a62ab172f43697a400adc8212c4ecd4
Author: ms <ms@taler.net>
Date:   Fri, 17 Dec 2021 11:02:03 +0100

bank: create withdrawal

Diffstat:
Mpackages/bank/src/pages/home/index.tsx | 140++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/bank/tests/__tests__/homepage.js | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 206 insertions(+), 35 deletions(-)

diff --git a/packages/bank/src/pages/home/index.tsx b/packages/bank/src/pages/home/index.tsx @@ -1,7 +1,8 @@ -import useSWR, { SWRConfig } from "swr"; +import useSWR, { SWRConfig, useSWRConfig } from "swr"; import { h, Fragment, ComponentChildren, VNode } from "preact"; import { useState, useEffect, StateUpdater } from "preact/hooks"; import { Buffer } from "buffer"; +import { useTranslator } from "../../i18n"; /********************************************** * Type definitions for states and API calls. * @@ -30,8 +31,9 @@ interface RegistrationRequestType { */ interface PageStateType { isLoggedIn: boolean; - hasProblem: boolean; + hasError: boolean; error?: string; + talerWithdrawUri?: string; } /** @@ -42,9 +44,9 @@ interface AccountStateType { /* FIXME: Need history here. */ } -/******************* +/************ * Helpers. * - ******************/ + ***********/ const getRootPath = () => { return typeof window !== undefined @@ -84,7 +86,7 @@ function useAccountState( function usePageState( state: PageStateType = { isLoggedIn: false, - hasProblem: false, + hasError: false, } ): [PageStateType, StateUpdater<PageStateType>] { return useState<PageStateType>(state); @@ -110,6 +112,76 @@ function usePageState( */ /** + * This function creates a withdrawal operation via the Access API. + * + * After having successfully created the withdrawal operation, the + * user should receive a QR code of the "taler://withdraw/" type and + * supposed to scan it with their phone. + * + * After the scan, the page should refresh itself and inform the user + * about the operation's outcome. + */ +async function createWithdrawalOperation( + amount: string, + backendState: BackendStateTypeOpt, + pageStateSetter: StateUpdater<PageStateType> +) { + if (typeof backendState === "undefined") { + console.log("Page has a problem: no credentials found in the state."); + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + error: "No credentials found in the state"})) + return; + } + try { + let headers = new Headers(); + /** + * NOTE: tests show that when a same object is being + * POSTed, caching might prevent same requests from being + * made. Hence, trying to POST twice the same amount might + * get silently ignored. + * + * headers.append("cache-control", "no-store"); + * headers.append("cache-control", "no-cache"); + * headers.append("pragma", "no-cache"); + * */ + headers.append( + "Authorization", + `Basic ${Buffer.from(backendState.username + ":" + backendState.password).toString("base64")}` + ); + var res = await fetch( + `${backendState.url}accounts/${backendState.username}/withdrawals`, { + method: 'POST', + headers: headers, + body: JSON.stringify({amount: amount}), + } + ); + } catch (error) { + console.log("Could not POST withdrawal request to the bank", error); + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + error: `Could not create withdrawal operation: ${error}`})) + return; + } + if (!res.ok) { + console.log(`Withdrawal creation gave response error (${res.status})`, res.statusText); + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + error: `Withdrawal creation gave response error (${res.status})`})) + return; + } else { + console.log("Withdrawal operation created!"); + let resp = await res.json(); + pageStateSetter((prevState) => ({ + ...prevState, + talerWithdrawUri: resp.taler_withdraw_uri})) + } +} + +/** * This function requests /register. * * This function is responsible to change two states: @@ -118,24 +190,31 @@ function usePageState( */ async function registrationCall( req: RegistrationRequestType, + /** + * FIXME: figure out if the two following + * functions can be retrieved somewhat from + * the state. + */ backendStateSetter: StateUpdater<BackendStateTypeOpt>, pageStateSetter: StateUpdater<PageStateType> ) { let baseUrl = getRootPath(); + let headersNoCache = new Headers(); try { var res = await fetch( - `${baseUrl}testing/register`, - {method: 'POST', body: JSON.stringify(req)} - ); + `${baseUrl}testing/register`, { + method: 'POST', + body: JSON.stringify(req), + }); } catch (error) { console.log("Could not POST new registration to the bank", error); - pageStateSetter((prevState) => ({ ...prevState, hasProblem: true })); + pageStateSetter((prevState) => ({ ...prevState, hasError: true })); return; } if (!res.ok) { console.log(`New registration gave response error (${res.status})`, res.statusText); - pageStateSetter((prevState) => ({ ...prevState, hasProblem: true })); + pageStateSetter((prevState) => ({ ...prevState, hasError: true })); } else { console.log("Credentials are valid"); pageStateSetter((prevState) => ({ ...prevState, isLoggedIn: true })); @@ -156,12 +235,28 @@ async function registrationCall( * Show only the account's balance. */ function Account(props: any) { + const { talerWithdrawUri, accountLabel } = props; const { data, error } = useSWR(`accounts/${props.accountLabel}`); if (typeof error != "undefined") { return <p>Account information could not be retrieved</p> } - if (!data) return <p>Retrieving the balance...</p>; - return <p>Your balance is {data.balance.amount}.</p>; + if (!data) return <p>Retrieving the profile page...</p>; + /** + * A Taler withdrawal replaces everything in the page and + * starts polling the backend until either the wallet selected + * a exchange and reserve public key, or a error / abort happened. + * + * After reaching one of the above states, the user should be + * brought to this ("Account") page where they get informed about + * the outcome. + */ + if (talerWithdrawUri) { + return <p>Give this address to your Taler wallet: {talerWithdrawUri}</p> + } + return <div> + <p>Your balance is {data.balance.amount}.</p> + {props.children} + </div> } /** @@ -196,8 +291,8 @@ export function BankHome(): VNode { var [pageState, pageStateSetter] = usePageState(); var [accountState, accountStateSetter] = useAccountState(); - if (pageState.hasProblem) { - return <p>Page has a problem.</p>; + if (pageState.hasError) { + return <p>Page has a problem: {pageState.error}</p>; } /** @@ -206,9 +301,8 @@ export function BankHome(): VNode { * history */ if (pageState.isLoggedIn) { if (typeof backendState === "undefined") { - console.log("Credentials not found in state, even after login.", backendState); - pageStateSetter((state) => ({ ...state, hasProblem: true })); - return <p>Page has a problem</p>; + pageStateSetter((prevState) => ({ ...prevState, hasError: true })); + return <p>Page has a problem: logged in but backend state is lost.</p>; } return ( <SWRWithCredentials @@ -216,7 +310,17 @@ export function BankHome(): VNode { password={backendState.password} backendUrl={backendState.url} > - <Account accountLabel={backendState.username} /> + <Account talerWithdrawUri={pageState.talerWithdrawUri} + accountLabel={backendState.username}> + <button + onClick={() => { + createWithdrawalOperation( + "EUR:5", + backendState, + pageStateSetter + )}} + >Charge Taler wallet</button> + </Account> </SWRWithCredentials> ); diff --git a/packages/bank/tests/__tests__/homepage.js b/packages/bank/tests/__tests__/homepage.js @@ -2,58 +2,117 @@ import "core-js/stable"; import "regenerator-runtime/runtime"; import { BankHome } from '../../src/pages/home'; import { h } from 'preact'; -import { render, fireEvent, screen } from '@testing-library/preact'; +import { cleanup, render, fireEvent, screen } from '@testing-library/preact'; import expect from 'expect'; import fetchMock from "jest-fetch-mock"; -fetchMock.enableMocks(); - -beforeEach(() => { - fetch.resetMocks(); -}); - -// Insert username and password into the registration -// form and returns the submit button. +/** + * Insert username and password into the registration + * form and returns the submit button. NOTE: the username + * must be given always fresh, as it acts as a SWR key and + * therefore might prevent calls from being made, because of + * caching reasons. That is not a problem per-se but can + * disrupt ".toHaveLastBeenCalledWith()"-like asserts. + * + * Return the username and the submit button. + */ function fillRegistrationForm() { + const username = Math.random().toString().substring(2); const u = screen.getByPlaceholderText("username"); const p = screen.getByPlaceholderText("password"); - fireEvent.input(u, {target: {value: "foo"}}) + fireEvent.input(u, {target: {value: username}}) fireEvent.input(p, {target: {value: "bar"}}) - return screen.getByText("Submit"); + const submitButton = screen.getByText("Submit"); + return {username: username, submitButton: submitButton}; } +fetchMock.enableMocks(); + +describe("withdraw", () => { + afterEach(() => { + fetch.resetMocks(); + cleanup(); + }) + + // Register and land on the profile page. + beforeEach(() => { + render(<BankHome />); + const { username, submitButton } = fillRegistrationForm(); + fetch.once("{}", { + status: 200 + }).once(JSON.stringify({ + balance: { + amount: "EUR:10", + credit_debit_indicator: "credit" + }, + paytoUri: "payto://iban/123/ABC" + })) + fireEvent.click(submitButton); + }) + + test("network failure before withdrawal creation", async () => { + let withdrawButton = screen.getByText("Charge Taler wallet"); + // mock network failure. + fetch.mockReject("API is down"); + fireEvent.click(withdrawButton); + await screen.findByText("could not create withdrawal operation", {exact: false}) + }) + + test("HTTP response error upon withdrawal creation", async () => { + let withdrawButton = screen.getByText("Charge Taler wallet"); + // mock network failure. + fetch.once("{}", {status: 404}); + fireEvent.click(withdrawButton); + await screen.findByText("gave response error", {exact: false}) + }) + + test("Successful withdraw", async () => { + let withdrawButton = screen.getByText("Charge Taler wallet"); + fetch.once(JSON.stringify({taler_withdraw_uri: "taler://foo"})); + fireEvent.click(withdrawButton); + await screen.findByText("give this address to your Taler wallet", {exact: false}) + }) +}) describe("home page", () => { + afterEach(() => { + fetch.resetMocks(); + cleanup(); + }) + // check page informs about the current balance // after a successful registration. test("new registration response error 404", async () => { render(<BankHome />); - let submitButton = fillRegistrationForm(); + let { username, submitButton } = fillRegistrationForm(); fetch.mockResponseOnce("Not found", {status: 404}) fireEvent.click(submitButton); await screen.findByText("has a problem", {exact: false}); expect(fetch).toHaveBeenCalledWith( "http://localhost/testing/register", - {body: JSON.stringify({username: "foo", password: "bar"}), method: "POST"}, + {body: JSON.stringify({username: username, password: "bar"}), method: "POST"}, ) }) test("registration network failure", async () => { render(<BankHome />); - let submitButton = fillRegistrationForm(); + const { username, submitButton } = fillRegistrationForm(); // Mocking network failure. fetch.mockReject("API is down"); fireEvent.click(submitButton); await screen.findByText("has a problem", {exact: false}); expect(fetch).toHaveBeenCalledWith( "http://localhost/testing/register", - {body: JSON.stringify({username: "foo", password: "bar"}), method: "POST"}, + {body: JSON.stringify({username: username, password: "bar"}), method: "POST"}, ) }) test("registration success", async () => { render(<BankHome />); - let submitButton = fillRegistrationForm(); + const { username, submitButton } = fillRegistrationForm(); + /** + * Mock successful registration and balance request. + */ fetch.once("{}", { status: 200 }).once(JSON.stringify({ @@ -64,9 +123,17 @@ describe("home page", () => { paytoUri: "payto://iban/123/ABC" })) fireEvent.click(submitButton); + /** + * Tests that a balance is shown after the successful + * registration. + */ await screen.findByText("balance is EUR:10", {exact: false}) + /** + * The expectation below tests whether the account + * balance was requested after the successful registration. + */ expect(fetch).toHaveBeenLastCalledWith( - "http://localhost/accounts/foo", + `http://localhost/accounts/${username}`, expect.anything() // no need to match auth headers. ) })