diff --git a/src/lib/__tests__/invoiceTags.test.ts b/src/lib/__tests__/invoiceTags.test.ts new file mode 100644 index 0000000..6e9a9cb --- /dev/null +++ b/src/lib/__tests__/invoiceTags.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + filterByTags, + setTags, + getTags, + __resetTagStore, + normalizeTag, + normalizeTags, + invoiceHasTag, +} from "../invoiceTags"; +import type { Invoice } from "@stellar-split/sdk"; + +function createMockInvoice(id: string, tags?: string[]): Invoice { + return { + id, + creator: "GCREATOR", + recipients: [{ address: "GRECIPIENT", amount: 1000n }], + token: "CDREXAMPLE", + deadline: Math.floor(Date.now() / 1000) + 3600, + funded: 0n, + status: "Pending", + payments: [], + ...(tags ? { tags } : {}), + } as unknown as Invoice; +} + +describe("invoiceTags", () => { + beforeEach(() => { + __resetTagStore(); + }); + + describe("filterByTags", () => { + const inv1 = createMockInvoice("inv-1"); // tags: ["urgent", "operations"] + const inv2 = createMockInvoice("inv-2"); // tags: ["operations", "audit"] + const inv3 = createMockInvoice("inv-3"); // tags: ["marketing"] + const inv4 = createMockInvoice("inv-4"); // tags: [] + + beforeEach(() => { + setTags("inv-1", ["urgent", "operations"]); + setTags("inv-2", ["operations", "audit"]); + setTags("inv-3", ["marketing"]); + setTags("inv-4", []); + }); + + it("returns all invoices unchanged when tags array is empty", () => { + const invoices = [inv1, inv2, inv3, inv4]; + const result = filterByTags(invoices, []); + expect(result).toEqual(invoices); + }); + + it("returns all invoices unchanged when tags only contain whitespace or empty strings", () => { + const invoices = [inv1, inv2, inv3, inv4]; + const result = filterByTags(invoices, [" ", ""]); + expect(result).toEqual(invoices); + }); + + it("filters with any-match (default matchAll: false)", () => { + const invoices = [inv1, inv2, inv3, inv4]; + const result = filterByTags(invoices, ["urgent", "marketing"]); + expect(result.map((i) => i.id)).toEqual(["inv-1", "inv-3"]); + }); + + it("filters with all-match (matchAll: true)", () => { + const invoices = [inv1, inv2, inv3, inv4]; + const result = filterByTags(invoices, ["urgent", "operations"], { matchAll: true }); + expect(result.map((i) => i.id)).toEqual(["inv-1"]); + }); + + it("returns empty array when no invoices match", () => { + const invoices = [inv1, inv2, inv3, inv4]; + const result = filterByTags(invoices, ["nonexistent-tag"]); + expect(result).toEqual([]); + }); + + it("is case-insensitive and normalizes whitespace in search tags", () => { + const invoices = [inv1, inv2, inv3, inv4]; + const result = filterByTags(invoices, [" URGENT "]); + expect(result.map((i) => i.id)).toEqual(["inv-1"]); + }); + + it("works with tags directly attached to the invoice object", () => { + const attached1 = createMockInvoice("custom-1", ["finance", "tax"]); + const attached2 = createMockInvoice("custom-2", ["tax"]); + const attached3 = createMockInvoice("custom-3", ["engineering"]); + + const list = [attached1, attached2, attached3]; + const anyTax = filterByTags(list, ["tax"]); + expect(anyTax.map((i) => i.id)).toEqual(["custom-1", "custom-2"]); + + const allTaxFinance = filterByTags(list, ["tax", "finance"], { matchAll: true }); + expect(allTaxFinance.map((i) => i.id)).toEqual(["custom-1"]); + }); + }); + + describe("normalization & lookup", () => { + it("normalizes single tag", () => { + expect(normalizeTag(" HELLO World ")).toBe("hello world"); + }); + + it("normalizes tag list and drops duplicates", () => { + expect(normalizeTags(["tag1", "TAG1", " tag2 ", ""])).toEqual(["tag1", "tag2"]); + }); + + it("checks tag presence case-insensitively", () => { + expect(invoiceHasTag(["Product", "Design"], "product")).toBe(true); + expect(invoiceHasTag(["Product", "Design"], "marketing")).toBe(false); + }); + }); +}); diff --git a/src/lib/invoiceTags.ts b/src/lib/invoiceTags.ts index e70026a..1079f18 100644 --- a/src/lib/invoiceTags.ts +++ b/src/lib/invoiceTags.ts @@ -6,6 +6,7 @@ * id — the same approach `api/invoices/[id]/route.ts` uses for splitMeta. */ import { z } from "zod"; +import type { Invoice } from "@stellar-split/sdk"; export const MAX_TAG_LENGTH = 32; export const MAX_TAGS_PER_INVOICE = 20; @@ -150,3 +151,42 @@ export function tagColorClass(tag: string): string { } return TAG_COLORS[hash % TAG_COLORS.length]; } + +export interface FilterByTagsOptions { + matchAll?: boolean; +} + +/** + * Filters a list of invoices by one or more tags. + * - Empty `tags` array returns all invoices unchanged. + * - Default (`matchAll: false`): returns invoices that have any of the given tags. + * - `matchAll: true`: returns only invoices that have all of the given tags. + */ +export function filterByTags( + invoices: Invoice[], + tags: string[], + opts: FilterByTagsOptions = {}, +): Invoice[] { + if (!tags || tags.length === 0) { + return invoices; + } + + const { matchAll = false } = opts; + const targetTags = tags.map((t) => normalizeTag(t)).filter(Boolean); + + if (targetTags.length === 0) { + return invoices; + } + + return invoices.filter((invoice) => { + const attachedTags = (invoice as { tags?: readonly string[] }).tags; + const invTags = Array.isArray(attachedTags) ? attachedTags : getTags(invoice.id); + + if (matchAll) { + return targetTags.every((target) => invoiceHasTag(invTags, target)); + } else { + return targetTags.some((target) => invoiceHasTag(invTags, target)); + } + }); +} +