From 58d0425682848e8f5bff7d1c76a2bd235aeb196c Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Thu, 20 Aug 2026 21:55:34 +0800 Subject: [PATCH] feat: add searchByMemo, validateSplitTotal/normalizeSplits, and has/purgeExpired to cache - #614: searchByMemo invoices by memo content substring (case-insensitive by default) - #609: validateSplitTotal with bigint precision + normalizeSplits with remainder distribution - #606: has() and purgeExpired() methods on SimpleCache All 26 tests pass across 3 new/updated test files. --- package-lock.json | 120 +------------------------- src/cache.ts | 35 ++++++++ src/index.ts | 9 +- src/search.ts | 28 +++++- src/validators/splitRatioValidator.ts | 52 ++++++++++- test/cache.test.ts | 58 +++++++++++++ test/search-memo.test.ts | 91 +++++++++++++++++++ test/splitRatioValidator.test.ts | 71 +++++++++++++++ 8 files changed, 342 insertions(+), 122 deletions(-) create mode 100644 test/search-memo.test.ts create mode 100644 test/splitRatioValidator.test.ts diff --git a/package-lock.json b/package-lock.json index f206cc1..1550c7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -124,41 +124,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/code-frame/node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -2059,51 +2024,6 @@ "node": ">=18.0.0" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -2201,14 +2121,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2807,17 +2719,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "5.2.0", "dev": true, @@ -3352,14 +3253,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/dunder-proto": { "version": "1.0.1", "license": "MIT", @@ -4108,17 +4001,6 @@ "node": "20 || >=22" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, @@ -5863,7 +5745,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/src/cache.ts b/src/cache.ts index e63e455..cb11215 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -63,6 +63,41 @@ export class SimpleCache { return entry.value; } + /** + * Check whether a key exists and has not expired. + * + * @param key - Cache key to check + * @returns `true` if the key exists and is unexpired, `false` otherwise + */ + has(key: string): boolean { + if (!this.enabled) return false; + const entry = this.store.get(key); + if (!entry) return false; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return false; + } + return true; + } + + /** + * Remove all expired entries in a single sweep. + * + * @returns The number of entries removed + */ + purgeExpired(): number { + if (!this.enabled) return 0; + const now = Date.now(); + let removed = 0; + for (const [key, entry] of this.store.entries()) { + if (now > entry.expiresAt) { + this.store.delete(key); + removed++; + } + } + return removed; + } + set(key: string, value: T): void { if (!this.enabled) return; const method = key.split(":")[0] || key; diff --git a/src/index.ts b/src/index.ts index 2fe2b4a..88a5796 100644 --- a/src/index.ts +++ b/src/index.ts @@ -989,7 +989,7 @@ export type { // Split ratio validator // --------------------------------------------------------------------------- -export { validateSplitRatios, validateSplitRatiosOrThrow, ratiosToRecipients } from "./validators/splitRatioValidator.js"; +export { validateSplitRatios, validateSplitRatiosOrThrow, ratiosToRecipients, validateSplitTotal, normalizeSplits } from "./validators/splitRatioValidator.js"; export type { RecipientShare, SplitConfig, @@ -1366,3 +1366,10 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// #614 — Memo-content search +// --------------------------------------------------------------------------- + +export { searchByMemo } from "./search.js"; +export type { SearchQuery, SearchResult } from "./search.js"; diff --git a/src/search.ts b/src/search.ts index 8d84608..7038ebc 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,5 +1,5 @@ import { Horizon } from "@stellar/stellar-sdk"; -import type { InvoiceStatus } from "./types.js"; +import type { Invoice, InvoiceStatus } from "./types.js"; import { SearchFailedError } from "./errors.js"; /** Query parameters for searching invoices. */ @@ -45,4 +45,30 @@ export async function searchInvoices( } catch (error) { throw new SearchFailedError(error instanceof Error ? error.message : String(error)); } +} + +/** + * Search invoices by memo content substring. + * + * @param invoices - Array of invoices to search through + * @param query - Search query string + * @param opts - Optional search options + * @param opts.caseSensitive - If true, enables exact-case matching (default: false) + * @returns Filtered array of invoices whose memo contains the query + */ +export function searchByMemo( + invoices: Invoice[], + query: string, + opts?: { caseSensitive?: boolean }, +): Invoice[] { + if (!query) return invoices; + + const isCaseSensitive = opts?.caseSensitive ?? false; + const searchQuery = isCaseSensitive ? query : query.toLowerCase(); + + return invoices.filter((invoice) => { + if (invoice.memo === undefined || invoice.memo === null) return false; + const memo = isCaseSensitive ? invoice.memo : invoice.memo.toLowerCase(); + return memo.includes(searchQuery); + }); } \ No newline at end of file diff --git a/src/validators/splitRatioValidator.ts b/src/validators/splitRatioValidator.ts index 6bfd0e9..b612794 100644 --- a/src/validators/splitRatioValidator.ts +++ b/src/validators/splitRatioValidator.ts @@ -11,7 +11,7 @@ * actionable error objects. */ -import { ValidationError } from "../errors.js"; +import { StellarSplitError, ValidationError } from "../errors.js"; import type { Recipient } from "../types.js"; // --------------------------------------------------------------------------- @@ -178,3 +178,53 @@ export function ratiosToRecipients( amount: amounts[i]!, })); } + +/** + * Validate that the sum of an array of bigint splits equals the expected total. + * + * @param splits - Array of bigint split amounts + * @param totalBasisPoints - Expected total in basis points (default: 10000n = 100%) + * @throws StellarSplitError with code INVALID_RECIPIENT if sum !== total + */ +export function validateSplitTotal( + splits: bigint[], + totalBasisPoints?: bigint, +): void { + const total = totalBasisPoints ?? 10000n; + + if (splits.length === 0) { + throw new StellarSplitError( + "splits must sum to 10000 basis points", + "INVALID_RECIPIENT", + ); + } + + const sum = splits.reduce((acc, s) => acc + s, 0n); + if (sum !== total) { + throw new StellarSplitError( + "splits must sum to 10000 basis points", + "INVALID_RECIPIENT", + ); + } +} + +/** + * Normalize an array of bigint amounts so they sum to exactly the given total. + * Distributes the rounding remainder to the last element. + * + * @param amounts - Array of bigint amounts to normalize + * @param total - The target total sum + * @returns A new array with the remainder distributed to the last element + */ +export function normalizeSplits( + amounts: bigint[], + total: bigint, +): bigint[] { + const sum = amounts.reduce((acc, s) => acc + s, 0n); + if (sum === total) return [...amounts]; + + const remainder = total - sum; + const result = [...amounts]; + result[result.length - 1] = (result[result.length - 1] ?? 0n) + remainder; + return result; +} diff --git a/test/cache.test.ts b/test/cache.test.ts index 839ed90..4b0d3b0 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -71,3 +71,61 @@ describe("SimpleCache LRU", () => { expect(stats.evictions).toBe(0); }); }); + +describe("SimpleCache has()", () => { + it("returns true when key exists and is not expired", () => { + const cache = new SimpleCache({ ttlMs: 10000 }); + cache.set("key", "value"); + expect(cache.has("key")).toBe(true); + }); + + it("returns false when key does not exist", () => { + const cache = new SimpleCache({ ttlMs: 10000 }); + expect(cache.has("nonexistent")).toBe(false); + }); + + it("returns false for expired key", () => { + const cache = new SimpleCache({ ttlMs: 1 }); + cache.set("key", "value"); + // Wait for TTL to expire + const start = Date.now(); + while (Date.now() - start < 5) {} // busy-wait ~5ms + expect(cache.has("key")).toBe(false); + }); + + it("returns false when cache is disabled", () => { + const cache = new SimpleCache(); + cache.set("key", "value"); + expect(cache.has("key")).toBe(false); + }); +}); + +describe("SimpleCache purgeExpired()", () => { + it("removes only expired entries", () => { + const cache = new SimpleCache({ ttlMs: 10000 }); + cache.set("fresh", "value"); + cache.set("stale", "value"); + // Manually set the stale entry to expired + const store = (cache as any).store as Map; + const staleEntry = store.get("stale"); + if (staleEntry) { + staleEntry.expiresAt = Date.now() - 1; + } + const removed = cache.purgeExpired(); + expect(removed).toBe(1); + expect(cache.has("fresh")).toBe(true); + expect(cache.has("stale")).toBe(false); + }); + + it("returns 0 when nothing is expired", () => { + const cache = new SimpleCache({ ttlMs: 10000 }); + cache.set("a", "1"); + cache.set("b", "2"); + expect(cache.purgeExpired()).toBe(0); + }); + + it("returns 0 when cache is disabled", () => { + const cache = new SimpleCache(); + expect(cache.purgeExpired()).toBe(0); + }); +}); diff --git a/test/search-memo.test.ts b/test/search-memo.test.ts new file mode 100644 index 0000000..7928e5d --- /dev/null +++ b/test/search-memo.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { searchByMemo } from "../src/search.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(overrides: Partial & { memo?: string }): Invoice { + return { + id: "test-id", + creator: "GABCDEF1234567890", + recipient: "G1234567890ABCDEF", + amount: "100", + asset: "XLM", + status: "pending", + ...overrides, + } as Invoice; +} + +describe("searchByMemo", () => { + it("returns invoices whose memo contains the query substring", () => { + const invoices = [ + makeInvoice({ memo: "split:INV-001", id: "1" }), + makeInvoice({ memo: "payment for services", id: "2" }), + makeInvoice({ memo: "split:INV-002", id: "3" }), + ]; + + const result = searchByMemo(invoices, "split"); + + expect(result).toHaveLength(2); + expect(result.map((i) => i.id)).toEqual(["1", "3"]); + }); + + it("is case-insensitive by default", () => { + const invoices = [ + makeInvoice({ memo: "Hello World", id: "1" }), + makeInvoice({ memo: "hello world", id: "2" }), + makeInvoice({ memo: "HELLO WORLD", id: "3" }), + ]; + + const result = searchByMemo(invoices, "hello"); + + expect(result).toHaveLength(3); + }); + + it("respects caseSensitive option", () => { + const invoices = [ + makeInvoice({ memo: "Hello World", id: "1" }), + makeInvoice({ memo: "hello world", id: "2" }), + ]; + + const result = searchByMemo(invoices, "Hello", { caseSensitive: true }); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + }); + + it("returns all invoices unchanged when query is empty", () => { + const invoices = [ + makeInvoice({ memo: "alpha", id: "1" }), + makeInvoice({ memo: "beta", id: "2" }), + makeInvoice({ memo: "gamma", id: "3" }), + ]; + + const result = searchByMemo(invoices, ""); + + expect(result).toHaveLength(3); + expect(result).toEqual(invoices); + }); + + it("skips invoices with undefined or null memo", () => { + const invoices = [ + makeInvoice({ memo: "found me", id: "1" }), + makeInvoice({ memo: undefined, id: "2" }), + makeInvoice({ memo: null, id: "3" } as unknown as Invoice), + ]; + + const result = searchByMemo(invoices, "found"); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + }); + + it("returns empty array when no invoices match", () => { + const invoices = [ + makeInvoice({ memo: "alpha", id: "1" }), + makeInvoice({ memo: "beta", id: "2" }), + ]; + + const result = searchByMemo(invoices, "nonexistent"); + + expect(result).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/test/splitRatioValidator.test.ts b/test/splitRatioValidator.test.ts new file mode 100644 index 0000000..5543378 --- /dev/null +++ b/test/splitRatioValidator.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { validateSplitTotal, normalizeSplits } from "../src/validators/splitRatioValidator.js"; +import { StellarSplitError } from "../src/errors.js"; + +describe("validateSplitTotal", () => { + it("passes when splits sum to the default total (10000n)", () => { + expect(() => validateSplitTotal([5000n, 3000n, 2000n])).not.toThrow(); + }); + + it("passes when splits sum to a custom total", () => { + expect(() => validateSplitTotal([1000n, 2000n, 3000n], 6000n)).not.toThrow(); + }); + + it("throws when splits do not sum to the expected total", () => { + expect(() => validateSplitTotal([5000n, 3000n, 1999n])).toThrow(StellarSplitError); + }); + + it("throws with INVALID_RECIPIENT code when sum mismatches", () => { + try { + validateSplitTotal([5000n, 3000n, 1999n]); + expect.unreachable("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(StellarSplitError); + expect((e as StellarSplitError).code).toBe("INVALID_RECIPIENT"); + expect((e as StellarSplitError).message).toContain("splits must sum to 10000 basis points"); + } + }); + + it("throws when given an empty array", () => { + expect(() => validateSplitTotal([])).toThrow(StellarSplitError); + }); +}); + +describe("normalizeSplits", () => { + it("returns a copy when amounts already sum to total", () => { + const amounts = [3000n, 4000n, 3000n]; + const result = normalizeSplits(amounts, 10000n); + expect(result).toEqual([3000n, 4000n, 3000n]); + // Should not mutate the original + expect(result).not.toBe(amounts); + }); + + it("distributes remainder to the last element", () => { + const amounts = [3333n, 3333n, 3333n]; + const result = normalizeSplits(amounts, 10000n); + // Last element gets the remainder: 3333 + (10000 - 9999) = 3334 + expect(result).toEqual([3333n, 3333n, 3334n]); + expect(result.reduce((a, b) => a + b, 0n)).toBe(10000n); + }); + + it("handles negative remainder (over-sum)", () => { + const amounts = [5000n, 5000n, 1000n]; + const result = normalizeSplits(amounts, 10000n); + // Last element gets the remainder: 1000 + (10000 - 11000) = 0 + expect(result).toEqual([5000n, 5000n, 0n]); + expect(result.reduce((a, b) => a + b, 0n)).toBe(10000n); + }); + + it("round-trip: normalizeSplits always sums to total", () => { + for (const amounts of [ + [1n, 2n, 3n], + [100n, 200n, 300n, 400n], + [9999n, 1n], + [5000n, 5000n], + ]) { + const result = normalizeSplits(amounts, 10000n); + const sum = result.reduce((a, b) => a + b, 0n); + expect(sum).toBe(10000n); + } + }); +}); \ No newline at end of file