taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 8703cbd97c7095d8866d63ee492a5eafc9d5b627
parent f2b3b5f28796d53b4f0445ce12abb66916dc25f0
Author: Florian Dold <florian@dold.me>
Date:   Sat, 14 Feb 2026 22:41:28 +0100

harness: report generation test

Diffstat:
Apackages/taler-harness/src/integrationtests/test-merchant-reports.ts | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/http-client/exchange-client.ts | 11++++++++++-
Mpackages/taler-util/src/http-client/merchant.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
4 files changed, 262 insertions(+), 23 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-merchant-reports.ts b/packages/taler-harness/src/integrationtests/test-merchant-reports.ts @@ -0,0 +1,175 @@ +/* + This file is part of GNU Taler + (C) 2023 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/> + */ + +/** + * Imports. + */ +import { + Duration, + succeedOrThrow, + TalerCorebankApiClient, + TalerMerchantApi, + TalerMerchantManagementHttpClient, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import * as fs from "node:fs"; +import { + applyTimeTravelV2, + makeTestPaymentV2, + useSharedTestkudosEnvironment, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +export async function runMerchantReportsTest(t: GlobalTestState) { + // Set up test environment + + const { walletClient, bank, exchange, merchant, merchantAdminAccessToken } = + await useSharedTestkudosEnvironment(t); + + const bankClient = new TalerCorebankApiClient(bank.baseUrl); + + await withdrawViaBankV3(t, { + walletClient, + bankClient, + exchange, + amount: "TESTKUDOS:30", + }); + + const instanceApi = new TalerMerchantManagementHttpClient( + merchant.makeInstanceBaseUrl(), + ); + + { + const reportBytes = succeedOrThrow( + await instanceApi.getStatisticsReportPdf( + merchantAdminAccessToken, + "transactions", + ), + ); + const f = t.testDir + `/report-0.pdf`; + fs.writeFileSync(f, Buffer.from(reportBytes)); + console.log(`written to ${f}`); + } + + { + const order: TalerMerchantApi.Order = { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + }; + + await makeTestPaymentV2(t, { + walletClient, + merchant, + merchantAdminAccessToken, + order, + }); + await walletClient.call( + WalletApiOperation.TestingWaitTransactionsFinal, + {}, + ); + } + + { + const reportBytes = succeedOrThrow( + await instanceApi.getStatisticsReportPdf( + merchantAdminAccessToken, + "transactions", + ), + ); + const f = t.testDir + `/report-1.pdf`; + fs.writeFileSync(f, Buffer.from(reportBytes)); + console.log(`written to ${f}`); + } + + { + const order: TalerMerchantApi.Order = { + summary: "Buy me again!", + amount: "TESTKUDOS:1", + fulfillment_url: "taler://fulfillment-success/thx", + }; + + await makeTestPaymentV2(t, { + walletClient, + merchant, + merchantAdminAccessToken, + order, + }); + await walletClient.call( + WalletApiOperation.TestingWaitTransactionsFinal, + {}, + ); + } + + { + const reportBytes = succeedOrThrow( + await instanceApi.getStatisticsReportPdf( + merchantAdminAccessToken, + "transactions", + ), + ); + const f = t.testDir + `/report-2.pdf`; + fs.writeFileSync(f, Buffer.from(reportBytes)); + console.log(`written to ${f}`); + } + + await applyTimeTravelV2( + Duration.toMilliseconds( + Duration.fromSpec({ + days: 2, + }), + ), + { + exchange, + merchant, + walletClient, + }, + ); + + { + const order: TalerMerchantApi.Order = { + summary: "Buy me again!", + amount: "TESTKUDOS:0.5", + fulfillment_url: "taler://fulfillment-success/thx", + }; + + await makeTestPaymentV2(t, { + walletClient, + merchant, + merchantAdminAccessToken, + order, + }); + await walletClient.call( + WalletApiOperation.TestingWaitTransactionsFinal, + {}, + ); + } + + { + const reportBytes = succeedOrThrow( + await instanceApi.getStatisticsReportPdf( + merchantAdminAccessToken, + "transactions", + ), + ); + const f = t.testDir + `/report-3.pdf`; + fs.writeFileSync(f, Buffer.from(reportBytes)); + console.log(`written to ${f}`); + } +} + +runMerchantReportsTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -113,6 +113,7 @@ import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js" import { runMerchantInstancesTest } from "./test-merchant-instances.js"; import { runMerchantLongpollingTest } from "./test-merchant-longpolling.js"; import { runMerchantRefundApiTest } from "./test-merchant-refund-api.js"; +import { runMerchantReportsTest } from "./test-merchant-reports.js"; import { runMerchantSelfProvisionActivationAndLoginTest } from "./test-merchant-self-provision-activation-and-login.js"; import { runMerchantSelfProvisionActivationTest } from "./test-merchant-self-provision-activation.js"; import { runMerchantSelfProvisionForgotPasswordTest } from "./test-merchant-self-provision-forgot-password.js"; @@ -419,6 +420,7 @@ const allTests: TestMainFunction[] = [ runWalletBbanTest, runCurrencyScopeSeparationTest, runWalletRefreshRedenominateTest, + runMerchantReportsTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -1218,7 +1218,16 @@ export class TalerExchangeHttpClient { auth: OfficerSession, account: string, params: PaginationParams = {}, - ) { + ): Promise< + | OperationOk<ArrayBuffer> + | OperationFail< + | HttpStatusCode.NoContent + | HttpStatusCode.Forbidden + | HttpStatusCode.NotFound + | HttpStatusCode.Conflict + | HttpStatusCode.NotImplemented + > + > { const url = new URL(`aml/${auth.id}/attributes/${account}`, this.baseUrl); addPaginationParams(url, params); diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -94,7 +94,7 @@ import { opKnownAlternativeHttpFailure, opKnownHttpFailure, opKnownTalerFailure, - opUnknownHttpFailure + opUnknownHttpFailure, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, @@ -247,9 +247,9 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationOk<TalerMerchantApi.LoginTokenSuccessResponse> | OperationAlternative< - HttpStatusCode.Accepted, - TalerMerchantApi.ChallengeResponse - > + HttpStatusCode.Accepted, + TalerMerchantApi.ChallengeResponse + > | OperationFail<HttpStatusCode.Unauthorized> > { const url = new URL(`private/token`, this.baseUrl); @@ -514,15 +514,12 @@ export class TalerMerchantInstanceHttpClient { fulfillmentUrl: string, params: { timeout?: number; - } = {} + } = {}, ) { const url = new URL(`sessions/${sessionId}`, this.baseUrl); if (fulfillmentUrl !== undefined) { - url.searchParams.set( - "fulfillment_url", - fulfillmentUrl, - ); + url.searchParams.set("fulfillment_url", fulfillmentUrl); } if (params.timeout) { url.searchParams.set("timeout_ms", String(params.timeout)); @@ -538,14 +535,14 @@ export class TalerMerchantInstanceHttpClient { resp, codecForGetSessionStatusPaidResponse(), ); - return { paid: true, ...final } + return { paid: true, ...final }; } case HttpStatusCode.Accepted: { const final = opSuccessFromHttp( resp, codecForGetSessionStatusPaidResponse(), ); - return { paid: false, ...final } + return { paid: false, ...final }; } case HttpStatusCode.NotFound: { return opKnownHttpFailure(resp.status, resp); @@ -847,16 +844,17 @@ export class TalerMerchantInstanceHttpClient { if (params.longpoll) { switch (params.longpoll.type) { case "state-enter": - url.searchParams.set("lp_status", (params.longpoll.status)); + url.searchParams.set("lp_status", params.longpoll.status); break; case "state-exit": url.searchParams.set("lp_not_status", params.longpoll.status); break; case "state-change": - url.searchParams.set("lp_not_etag", (params.longpoll.etag)); - headers["If-none-match"] = `"${params.longpoll.etag}"` + url.searchParams.set("lp_not_etag", params.longpoll.etag); + headers["If-none-match"] = `"${params.longpoll.etag}"`; break; - default: assertUnreachable(params.longpoll) + default: + assertUnreachable(params.longpoll); } url.searchParams.set("timeout_ms", String(params.longpoll.timeout)); } else { @@ -881,12 +879,12 @@ export class TalerMerchantInstanceHttpClient { switch (resp.status) { case HttpStatusCode.Ok: { const f = await opSuccessFromHttp(resp, codecForAccountKycRedirects()); - return opFixedSuccess({ etag, kycRequired: true as const, ...f.body }) + return opFixedSuccess({ etag, kycRequired: true as const, ...f.body }); } case HttpStatusCode.NoContent: - return opFixedSuccess({ etag, kycRequired: false as const }) + return opFixedSuccess({ etag, kycRequired: false as const }); case HttpStatusCode.NotModified: - return opFixedSuccess({ etag, kycRequired: undefined }) + return opFixedSuccess({ etag, kycRequired: undefined }); case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: // FIXME: missing in docs @@ -1648,8 +1646,8 @@ export class TalerMerchantInstanceHttpClient { const headers: Record<string, string> = {}; if (params.longpoll) { - url.searchParams.set("lp_not_etag", (params.longpoll.etag)); - headers["If-none-match"] = `"${params.longpoll.etag}"` + url.searchParams.set("lp_not_etag", params.longpoll.etag); + headers["If-none-match"] = `"${params.longpoll.etag}"`; url.searchParams.set("timeout_ms", String(params.longpoll.timeout)); } else { // backward compat, prefer longpoll @@ -1673,7 +1671,7 @@ export class TalerMerchantInstanceHttpClient { resp, codecForMerchantOrderPrivateStatusResponse(), ); - return opFixedSuccess({ etag, ...f.body }) + return opFixedSuccess({ etag, ...f.body }); } case HttpStatusCode.NotFound: { const details = await readTalerErrorResponse(resp); @@ -3784,6 +3782,8 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp } } + // FIXME: This should not go into the management API, but + // the instance API. /** * https://docs.taler.net/core/api-merchant.html#get--management-instances-$INSTANCE-statistics-report-$NAME */ @@ -3791,7 +3791,13 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp token: AccessToken, name: "transactions" | "money-pots" | "taxes" | "sales-funnel", params: TalerMerchantApi.GetStatisticsReportParams = {}, - ) { + ): Promise< + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.Gone> + | OperationFail<HttpStatusCode.Unauthorized> + | OperationOk<TalerMerchantApi.MerchantStatisticsReportResponse> + | OperationFail<HttpStatusCode.NotImplemented> + > { const url = new URL(`private/statistics-report/${name}`, this.baseUrl); if (params.count !== undefined) { @@ -3827,4 +3833,51 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp return opUnknownHttpFailure(resp); } } + + // FIXME: This should not go into the management API, but + // the instance API. + async getStatisticsReportPdf( + token: AccessToken, + name: "transactions" | "money-pots" | "taxes" | "sales-funnel", + params: TalerMerchantApi.GetStatisticsReportParams = {}, + ): Promise< + | OperationOk<ArrayBuffer> + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.Gone> + | OperationFail<HttpStatusCode.Unauthorized> + | OperationFail<HttpStatusCode.NotImplemented> + > { + const url = new URL(`private/statistics-report/${name}`, this.baseUrl); + + if (params.count !== undefined) { + url.searchParams.set("count", String(params.count)); + } + if (params.granularity) { + url.searchParams.set("granularity", params.granularity); + } + + const headers: Record<string, string> = {}; + if (token) { + headers.Authorization = makeBearerTokenAuthHeader(token); + } + headers["Accept"] = "application/pdf"; + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + headers, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opFixedSuccess(await resp.bytes()); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Unauthorized: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Gone: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotImplemented: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } }