commit 8703cbd97c7095d8866d63ee492a5eafc9d5b627
parent f2b3b5f28796d53b4f0445ce12abb66916dc25f0
Author: Florian Dold <florian@dold.me>
Date: Sat, 14 Feb 2026 22:41:28 +0100
harness: report generation test
Diffstat:
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);
+ }
+ }
}