commit 6af0cb6842298379e7db0c93f000935424390bbf
parent 16765d1477999b3d513e265c1e465a825d1face2
Author: Florian Dold <dold@taler.net>
Date: Tue, 21 Jul 2026 02:03:29 +0200
util: test the native-request timeout and cancellation helper
Diffstat:
1 file changed, 98 insertions(+), 0 deletions(-)
diff --git a/packages/taler-util/src/http-native-request.test.ts b/packages/taler-util/src/http-native-request.test.ts
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 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/>
+ */
+
+// The qtart HTTP backend cannot be imported under node (it pulls in the
+// QuickJS "std" module), so its timeout/cancellation/cleanup logic lives in a
+// pure helper here that is exercised with a stubbed native request.
+
+import { test } from "node:test";
+import assert from "node:assert";
+import * as httpCommon from "./http-common.js";
+import { CancellationToken } from "./CancellationToken.js";
+import { Duration } from "./time.js";
+
+// Resolved dynamically so this file compiles before the helper exists.
+const awaitNativeRequest = (httpCommon as any).awaitNativeRequest as <T>(
+ native: { promise: Promise<T>; cancelFn: () => void },
+ opts: { timeout?: Duration; cancellationToken?: CancellationToken },
+) => Promise<T>;
+
+function stubNative<T>(kind: "never" | "resolve" | "reject", value?: any) {
+ let cancels = 0;
+ const promise: Promise<T> =
+ kind === "never"
+ ? new Promise<T>(() => {})
+ : kind === "resolve"
+ ? Promise.resolve(value)
+ : Promise.reject(value ?? new Error("native failure"));
+ return {
+ native: { promise, cancelFn: () => void cancels++ },
+ get cancels() {
+ return cancels;
+ },
+ };
+}
+
+test("timeout cancels the native request and throws", async (t) => {
+ const s = stubNative("never");
+ await assert.rejects(
+ awaitNativeRequest(s.native, { timeout: Duration.fromMilliseconds(20) }),
+ /timed out/i,
+ );
+ assert.strictEqual(s.cancels, 1);
+});
+
+test("cancellation cancels the native request and throws", async (t) => {
+ const s = stubNative("never");
+ const src = CancellationToken.create();
+ const p = awaitNativeRequest(s.native, { cancellationToken: src.token });
+ src.cancel();
+ await assert.rejects(p, /cancel/i);
+ assert.strictEqual(s.cancels, 1);
+});
+
+test("a successful request returns its value", async (t) => {
+ const s = stubNative<string>("resolve", "ok");
+ const r = await awaitNativeRequest(s.native, {
+ timeout: Duration.fromMilliseconds(10000),
+ });
+ assert.strictEqual(r, "ok");
+});
+
+test("no cancellation-listener leak across failed requests", async (t) => {
+ const src = CancellationToken.create();
+ const callbacks = () => (src.token as any)._callbacks.size as number;
+ const before = callbacks();
+ for (let i = 0; i < 50; i++) {
+ const s = stubNative("reject");
+ await awaitNativeRequest(s.native, {
+ timeout: Duration.fromMilliseconds(10000),
+ cancellationToken: src.token,
+ }).catch(() => undefined);
+ }
+ assert.strictEqual(callbacks(), before);
+});
+
+test("no armed-timer leak: the timeout does not fire after the request settled", async (t) => {
+ // On a failed request the timer must be cleared; otherwise it fires later and
+ // cancels an already-finished request.
+ const s = stubNative("reject");
+ await awaitNativeRequest(s.native, {
+ timeout: Duration.fromMilliseconds(20),
+ }).catch(() => undefined);
+ await new Promise((r) => setTimeout(r, 60));
+ assert.strictEqual(s.cancels, 0);
+});