wallet-core: use testingWaitTransactionsFinal to wait for transactions

This commit is contained in:
Florian Dold 2023-07-01 01:43:29 +02:00
parent f93ab03a1b
commit 5695ae0a9f
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
7 changed files with 157 additions and 30 deletions

View File

@ -41,17 +41,21 @@ import {
Logger, Logger,
MerchantReserveCreateConfirmation, MerchantReserveCreateConfirmation,
MerchantTemplateAddDetails, MerchantTemplateAddDetails,
NotificationType,
parsePaytoUri, parsePaytoUri,
stringToBytes, stringToBytes,
TalerError, TalerError,
TalerProtocolDuration, TalerProtocolDuration,
TransactionMajorState,
WalletNotification, WalletNotification,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
BankApi, BankApi,
BankServiceHandle, BankServiceHandle,
HarnessExchangeBankAccount, HarnessExchangeBankAccount,
OpenedPromise,
openPromise, openPromise,
WalletApiOperation,
WalletCoreApiClient, WalletCoreApiClient,
WalletCoreRequestType, WalletCoreRequestType,
WalletCoreResponseType, WalletCoreResponseType,
@ -934,7 +938,12 @@ export class FakebankService
); );
await this.pingUntilAvailable(); await this.pingUntilAvailable();
for (const acc of this.accounts) { for (const acc of this.accounts) {
await BankApi.registerAccount(this, acc.accountName, acc.accountPassword, {}); await BankApi.registerAccount(
this,
acc.accountName,
acc.accountPassword,
{},
);
} }
} }
@ -2246,9 +2255,26 @@ export interface WalletClientArgs {
onNotification?(n: WalletNotification): void; onNotification?(n: WalletNotification): void;
} }
export type CancelFn = () => void;
export type NotificationHandler = (n: WalletNotification) => void;
/**
* Convenience wrapper around a (remote) wallet handle.
*/
export class WalletClient { export class WalletClient {
remoteWallet: RemoteWallet | undefined = undefined; remoteWallet: RemoteWallet | undefined = undefined;
private waiter: WalletNotificationWaiter = makeNotificationWaiter(); private waiter: WalletNotificationWaiter = makeNotificationWaiter();
notificationHandlers: NotificationHandler[] = [];
addNotificationListener(f: NotificationHandler): CancelFn {
this.notificationHandlers.push(f);
return () => {
const idx = this.notificationHandlers.indexOf(f);
if (idx >= 0) {
this.notificationHandlers.splice(idx, 1);
}
};
}
async call<Op extends keyof WalletOperations>( async call<Op extends keyof WalletOperations>(
operation: Op, operation: Op,
@ -2260,6 +2286,7 @@ export class WalletClient {
const client = getClientFromRemoteWallet(this.remoteWallet); const client = getClientFromRemoteWallet(this.remoteWallet);
return client.call(operation, payload); return client.call(operation, payload);
} }
constructor(private args: WalletClientArgs) {} constructor(private args: WalletClientArgs) {}
async connect(): Promise<void> { async connect(): Promise<void> {
@ -2272,6 +2299,9 @@ export class WalletClient {
walletClient.args.onNotification(n); walletClient.args.onNotification(n);
} }
waiter.notify(n); waiter.notify(n);
for (const h of walletClient.notificationHandlers) {
h(n);
}
}, },
}); });
this.remoteWallet = w; this.remoteWallet = w;

View File

@ -689,3 +689,73 @@ export async function makeTestPayment(
t.assertTrue(orderStatus.order_status === "paid"); t.assertTrue(orderStatus.order_status === "paid");
} }
/**
* Make a simple payment and check that it succeeded.
*/
export async function makeTestPaymentV2(
t: GlobalTestState,
args: {
merchant: MerchantServiceInterface;
walletClient: WalletClient;
order: Partial<MerchantContractTerms>;
instance?: string;
},
auth: WithAuthorization = {},
): Promise<void> {
// Set up order.
const { walletClient, merchant } = args;
const instance = args.instance ?? "default";
const orderResp = await MerchantPrivateApi.createOrder(
merchant,
instance,
{
order: args.order,
},
auth,
);
let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
merchant,
{
orderId: orderResp.order_id,
},
auth,
);
t.assertTrue(orderStatus.order_status === "unpaid");
// Make wallet pay for the order
const preparePayResult = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: orderStatus.taler_pay_uri,
},
);
t.assertTrue(
preparePayResult.status === PreparePayResultType.PaymentPossible,
);
const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
proposalId: preparePayResult.proposalId,
});
t.assertTrue(r2.type === ConfirmPayResultType.Done);
// Check if payment was successful.
orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
merchant,
{
orderId: orderResp.order_id,
instance,
},
auth,
);
t.assertTrue(orderStatus.order_status === "paid");
}

View File

@ -20,9 +20,9 @@
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState } from "../harness/harness.js"; import { GlobalTestState } from "../harness/harness.js";
import { import {
createSimpleTestkudosEnvironment, createSimpleTestkudosEnvironmentV2,
withdrawViaBank, withdrawViaBankV2,
makeTestPayment, makeTestPaymentV2,
} from "../harness/helpers.js"; } from "../harness/helpers.js";
import { j2s } from "@gnu-taler/taler-util"; import { j2s } from "@gnu-taler/taler-util";
@ -32,12 +32,14 @@ import { j2s } from "@gnu-taler/taler-util";
export async function runPaymentTest(t: GlobalTestState) { export async function runPaymentTest(t: GlobalTestState) {
// Set up test environment // Set up test environment
const { wallet, bank, exchange, merchant } = const { walletClient, bank, exchange, merchant } =
await createSimpleTestkudosEnvironment(t); await createSimpleTestkudosEnvironmentV2(t);
// Withdraw digital cash into the wallet. // Withdraw digital cash into the wallet.
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" }); await withdrawViaBankV2(t, { walletClient, bank, exchange, amount: "TESTKUDOS:20" });
await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
const order = { const order = {
summary: "Buy me!", summary: "Buy me!",
@ -45,8 +47,8 @@ export async function runPaymentTest(t: GlobalTestState) {
fulfillment_url: "taler://fulfillment-success/thx", fulfillment_url: "taler://fulfillment-success/thx",
}; };
await makeTestPayment(t, { wallet, merchant, order }); await makeTestPaymentV2(t, { walletClient, merchant, order });
await wallet.runUntilDone(); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
// Test JSON normalization of contract terms: Does the wallet // Test JSON normalization of contract terms: Does the wallet
// agree with the merchant? // agree with the merchant?
@ -56,8 +58,8 @@ export async function runPaymentTest(t: GlobalTestState) {
fulfillment_url: "taler://fulfillment-success/thx", fulfillment_url: "taler://fulfillment-success/thx",
}; };
await makeTestPayment(t, { wallet, merchant, order: order2 }); await makeTestPaymentV2(t, { walletClient, merchant, order: order2 });
await wallet.runUntilDone(); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
// Test JSON normalization of contract terms: Does the wallet // Test JSON normalization of contract terms: Does the wallet
// agree with the merchant? // agree with the merchant?
@ -67,11 +69,10 @@ export async function runPaymentTest(t: GlobalTestState) {
fulfillment_url: "taler://fulfillment-success/thx", fulfillment_url: "taler://fulfillment-success/thx",
}; };
await makeTestPayment(t, { wallet, merchant, order: order3 }); await makeTestPaymentV2(t, { walletClient, merchant, order: order3 });
await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
await wallet.runUntilDone(); const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
console.log(`balance after 3 payments: ${j2s(bal)}`); console.log(`balance after 3 payments: ${j2s(bal)}`);
t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:3.8"); t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:3.8");
t.assertAmountEquals(bal.balances[0].pendingIncoming, "TESTKUDOS:0"); t.assertAmountEquals(bal.balances[0].pendingIncoming, "TESTKUDOS:0");

View File

@ -263,22 +263,25 @@ async function refreshCreateSession(
availableAmount, availableAmount,
)} too small`, )} too small`,
); );
// FIXME: State transition notification missing. const transitionInfo = await ws.db
await ws.db
.mktx((x) => [x.coins, x.coinAvailability, x.refreshGroups]) .mktx((x) => [x.coins, x.coinAvailability, x.refreshGroups])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId); const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) { if (!rg) {
return; return;
} }
const oldTxState = computeRefreshTransactionState(rg);
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
const updateRes = updateGroupStatus(rg); const updateRes = updateGroupStatus(rg);
if (updateRes.final) { if (updateRes.final) {
await makeCoinsVisible(ws, tx, transactionId); await makeCoinsVisible(ws, tx, transactionId);
} }
await tx.refreshGroups.put(rg); await tx.refreshGroups.put(rg);
const newTxState = computeRefreshTransactionState(rg);
return { oldTxState, newTxState };
}); });
ws.notify({ type: NotificationType.BalanceChange }); ws.notify({ type: NotificationType.BalanceChange });
notifyTransition(ws, transactionId, transitionInfo);
return; return;
} }
@ -438,7 +441,7 @@ async function refreshMelt(
if (resp.status === HttpStatusCode.NotFound) { if (resp.status === HttpStatusCode.NotFound) {
const errDetails = await readUnexpectedResponseDetails(resp); const errDetails = await readUnexpectedResponseDetails(resp);
await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.refreshGroups, x.coins, x.coinAvailability]) .mktx((x) => [x.refreshGroups, x.coins, x.coinAvailability])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId); const rg = await tx.refreshGroups.get(refreshGroupId);
@ -451,6 +454,7 @@ async function refreshMelt(
if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
return; return;
} }
const oldTxState = computeRefreshTransactionState(rg);
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
rg.lastErrorPerCoin[coinIndex] = errDetails; rg.lastErrorPerCoin[coinIndex] = errDetails;
const updateRes = updateGroupStatus(rg); const updateRes = updateGroupStatus(rg);
@ -458,8 +462,14 @@ async function refreshMelt(
await makeCoinsVisible(ws, tx, transactionId); await makeCoinsVisible(ws, tx, transactionId);
} }
await tx.refreshGroups.put(rg); await tx.refreshGroups.put(rg);
const newTxState = computeRefreshTransactionState(rg);
return {
oldTxState,
newTxState,
};
}); });
ws.notify({ type: NotificationType.BalanceChange }); ws.notify({ type: NotificationType.BalanceChange });
notifyTransition(ws, transactionId, transitionInfo);
return; return;
} }
@ -739,7 +749,7 @@ async function refreshReveal(
} }
} }
await ws.db const transitionInfo = await ws.db
.mktx((x) => [ .mktx((x) => [
x.coins, x.coins,
x.denominations, x.denominations,
@ -756,6 +766,7 @@ async function refreshReveal(
if (!rs) { if (!rs) {
return; return;
} }
const oldTxState = computeRefreshTransactionState(rg);
rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
updateGroupStatus(rg); updateGroupStatus(rg);
for (const coin of coins) { for (const coin of coins) {
@ -763,7 +774,10 @@ async function refreshReveal(
} }
await makeCoinsVisible(ws, tx, transactionId); await makeCoinsVisible(ws, tx, transactionId);
await tx.refreshGroups.put(rg); await tx.refreshGroups.put(rg);
const newTxState = computeRefreshTransactionState(rg);
return { oldTxState, newTxState };
}); });
notifyTransition(ws, transactionId, transitionInfo);
logger.trace("refresh finished (end of reveal)"); logger.trace("refresh finished (end of reveal)");
} }
@ -778,7 +792,7 @@ export async function processRefreshGroup(
.mktx((x) => [x.refreshGroups]) .mktx((x) => [x.refreshGroups])
.runReadOnly(async (tx) => tx.refreshGroups.get(refreshGroupId)); .runReadOnly(async (tx) => tx.refreshGroups.get(refreshGroupId));
if (!refreshGroup) { if (!refreshGroup) {
return TaskRunResult.finished() return TaskRunResult.finished();
} }
if (refreshGroup.timestampFinished) { if (refreshGroup.timestampFinished) {
return TaskRunResult.finished(); return TaskRunResult.finished();
@ -1235,10 +1249,6 @@ export async function suspendRefreshGroup(
tag: TransactionType.Refresh, tag: TransactionType.Refresh,
refreshGroupId, refreshGroupId,
}); });
const retryTag = constructTaskIdentifier({
tag: PendingTaskType.Refresh,
refreshGroupId,
});
let res = await ws.db let res = await ws.db
.mktx((x) => [x.refreshGroups]) .mktx((x) => [x.refreshGroups])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {

View File

@ -450,7 +450,7 @@ export async function runIntegrationTest(
logger.trace("integration test: all done!"); logger.trace("integration test: all done!");
} }
async function waitUntilDone(ws: InternalWalletState): Promise<void> { export async function waitUntilDone(ws: InternalWalletState): Promise<void> {
logger.info("waiting until all transactions are in a final state"); logger.info("waiting until all transactions are in a final state");
ws.ensureTaskLoopRunning(); ws.ensureTaskLoopRunning();
let p: OpenedPromise<void> | undefined = undefined; let p: OpenedPromise<void> | undefined = undefined;
@ -459,11 +459,13 @@ async function waitUntilDone(ws: InternalWalletState): Promise<void> {
return; return;
} }
if (notif.type === NotificationType.TransactionStateTransition) { if (notif.type === NotificationType.TransactionStateTransition) {
p.resolve(); switch (notif.newTxState.major) {
} case TransactionMajorState.Pending:
// Work-around, refresh transactions don't properly emit transition notifications yet. case TransactionMajorState.Aborting:
if (notif.type === NotificationType.PendingOperationProcessed) { break;
p.resolve(); default:
p.resolve();
}
} }
}); });
while (1) { while (1) {

View File

@ -206,6 +206,7 @@ export enum WalletApiOperation {
Recycle = "recycle", Recycle = "recycle",
ApplyDevExperiment = "applyDevExperiment", ApplyDevExperiment = "applyDevExperiment",
ValidateIban = "validateIban", ValidateIban = "validateIban",
TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
} }
// group: Initialization // group: Initialization
@ -949,6 +950,15 @@ export type DumpCoinsOp = {
response: CoinDumpJson; response: CoinDumpJson;
}; };
/**
* Wait until all transactions are in a final state.
*/
export type TestingWaitTransactionsFinal = {
op: WalletApiOperation.TestingWaitTransactionsFinal;
request: EmptyObject;
response: EmptyObject;
};
/** /**
* Set a coin as (un-)suspended. * Set a coin as (un-)suspended.
* Suspended coins won't be used for payments. * Suspended coins won't be used for payments.
@ -1051,6 +1061,7 @@ export type WalletOperations = {
[WalletApiOperation.Recycle]: RecycleOp; [WalletApiOperation.Recycle]: RecycleOp;
[WalletApiOperation.ApplyDevExperiment]: ApplyDevExperimentOp; [WalletApiOperation.ApplyDevExperiment]: ApplyDevExperimentOp;
[WalletApiOperation.ValidateIban]: ValidateIbanOp; [WalletApiOperation.ValidateIban]: ValidateIbanOp;
[WalletApiOperation.TestingWaitTransactionsFinal]: TestingWaitTransactionsFinal;
}; };
export type WalletCoreRequestType< export type WalletCoreRequestType<

View File

@ -242,6 +242,7 @@ import {
runIntegrationTest, runIntegrationTest,
runIntegrationTest2, runIntegrationTest2,
testPay, testPay,
waitUntilDone,
withdrawTestBalance, withdrawTestBalance,
} from "./operations/testing.js"; } from "./operations/testing.js";
import { import {
@ -1550,6 +1551,8 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
case WalletApiOperation.GetVersion: { case WalletApiOperation.GetVersion: {
return getVersion(ws); return getVersion(ws);
} }
case WalletApiOperation.TestingWaitTransactionsFinal:
return await waitUntilDone(ws);
// default: // default:
// assertUnreachable(operation); // assertUnreachable(operation);
} }