diff --git a/features/duplicate-transaction-scanner/__tests__/fingerprint.test.ts b/features/duplicate-transaction-scanner/__tests__/fingerprint.test.ts new file mode 100644 index 0000000..61b1e0f --- /dev/null +++ b/features/duplicate-transaction-scanner/__tests__/fingerprint.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + computeFingerprint, + groupByFingerprint, + TIME_BUCKET_SECONDS, + timeBucketFor, +} from "@/features/duplicate-transaction-scanner/lib/fingerprint"; +import { + TX_A1, + TX_A2, + TX_B1, +} from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture"; + +describe("timeBucketFor", () => { + it("returns the same bucket for two timestamps in the same 5-minute window", () => { + const t1 = "2026-10-18T22:05:30.000Z"; + const t2 = "2026-10-18T22:06:00.000Z"; + expect(timeBucketFor(t1)).toBe(timeBucketFor(t2)); + }); + + it("returns different buckets for timestamps in different windows", () => { + const t1 = "2026-10-18T22:05:30.000Z"; + const t2 = "2026-10-18T22:15:00.000Z"; + expect(timeBucketFor(t1)).not.toBe(timeBucketFor(t2)); + }); + + it("the bucket width equals TIME_BUCKET_SECONDS", () => { + const base = new Date("2026-10-18T22:00:00.000Z").getTime() / 1000; + const b1 = Math.floor(base / TIME_BUCKET_SECONDS); + const b2 = Math.floor((base + TIME_BUCKET_SECONDS - 1) / TIME_BUCKET_SECONDS); + const b3 = Math.floor((base + TIME_BUCKET_SECONDS) / TIME_BUCKET_SECONDS); + expect(b1).toBe(b2); // within the same bucket + expect(b1).not.toBe(b3); // across the boundary + }); +}); + +describe("computeFingerprint", () => { + it("produces a 64-char hex digest", () => { + const fp = computeFingerprint(TX_A1, "testnet"); + expect(fp.value).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces the same fingerprint for two structurally identical transactions", () => { + const fp1 = computeFingerprint(TX_A1, "testnet"); + const fp2 = computeFingerprint(TX_A2, "testnet"); + expect(fp1.value).toBe(fp2.value); + }); + + it("produces different fingerprints for structurally distinct transactions", () => { + const fp1 = computeFingerprint(TX_A1, "testnet"); + const fp2 = computeFingerprint(TX_B1, "testnet"); + expect(fp1.value).not.toBe(fp2.value); + }); + + it("is sensitive to the network field", () => { + const fpTestnet = computeFingerprint(TX_A1, "testnet"); + const fpMainnet = computeFingerprint(TX_A1, "mainnet"); + expect(fpTestnet.value).not.toBe(fpMainnet.value); + }); + + it("is sensitive to memo type", () => { + const noMemo = computeFingerprint(TX_A1, "testnet"); + const withMemo = computeFingerprint({ ...TX_A1, memoType: "text", memo: "hello" }, "testnet"); + expect(noMemo.value).not.toBe(withMemo.value); + }); + + it("op type order does not affect the fingerprint (order-insensitive)", () => { + const txAscending = { ...TX_A1, operationTypes: ["create_account", "payment"] }; + const txDescending = { ...TX_A1, operationTypes: ["payment", "create_account"] }; + const fp1 = computeFingerprint(txAscending, "testnet"); + const fp2 = computeFingerprint(txDescending, "testnet"); + expect(fp1.value).toBe(fp2.value); + }); + + it("stores the pre-image fields on the fingerprint object", () => { + const fp = computeFingerprint(TX_A1, "testnet"); + expect(fp.sourceAccount).toBe(TX_A1.sourceAccount); + expect(fp.feeCharged).toBe(TX_A1.feeCharged); + expect(fp.network).toBe("testnet"); + expect(fp.opTypesSorted).toEqual(["payment"]); + }); +}); + +describe("groupByFingerprint", () => { + it("returns one group for the two transactions with identical fingerprints", () => { + const groups = groupByFingerprint([TX_A1, TX_A2, TX_B1], "testnet"); + expect(groups).toHaveLength(1); + expect(groups[0].transactions).toHaveLength(2); + }); + + it("returns an empty array when no duplicates exist", () => { + const groups = groupByFingerprint([TX_A1, TX_B1], "testnet"); + expect(groups).toHaveLength(0); + }); + + it("returns an empty array for a single transaction", () => { + expect(groupByFingerprint([TX_A1], "testnet")).toHaveLength(0); + }); + + it("returns an empty array for an empty list", () => { + expect(groupByFingerprint([], "testnet")).toHaveLength(0); + }); + + it("groups start with state pending", () => { + const groups = groupByFingerprint([TX_A1, TX_A2], "testnet"); + expect(groups[0].state).toBe("pending"); + }); + + it("group id equals the fingerprint value", () => { + const groups = groupByFingerprint([TX_A1, TX_A2], "testnet"); + expect(groups[0].id).toBe(groups[0].fingerprint.value); + }); + + it("is deterministic — same input, same output", () => { + const g1 = groupByFingerprint([TX_A1, TX_A2], "testnet"); + const g2 = groupByFingerprint([TX_A1, TX_A2], "testnet"); + expect(g1[0].id).toBe(g2[0].id); + }); +}); diff --git a/features/duplicate-transaction-scanner/__tests__/format.test.ts b/features/duplicate-transaction-scanner/__tests__/format.test.ts new file mode 100644 index 0000000..617b31e --- /dev/null +++ b/features/duplicate-transaction-scanner/__tests__/format.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + formatFee, + formatGroupState, + formatOpTypes, + formatTimeBucket, +} from "@/features/duplicate-transaction-scanner/lib/format"; + +describe("formatFee", () => { + it("renders a fee in stroops and XLM", () => { + const result = formatFee("100"); + expect(result).toContain("100 stroops"); + expect(result).toContain("XLM"); + }); + + it("handles 10,000,000 stroops as exactly 1 XLM", () => { + const result = formatFee("10000000"); + expect(result).toContain("10000000 stroops"); + expect(result).toContain("1.0000000 XLM"); + }); + + it("handles 0 fee gracefully", () => { + expect(() => formatFee("0")).not.toThrow(); + }); +}); + +describe("formatOpTypes", () => { + it("joins multiple types with comma", () => { + expect(formatOpTypes(["payment", "create_account"])).toBe("payment, create_account"); + }); + + it("returns an em dash for an empty list", () => { + expect(formatOpTypes([])).toBe("—"); + }); + + it("returns a single type without punctuation", () => { + expect(formatOpTypes(["payment"])).toBe("payment"); + }); +}); + +describe("formatGroupState", () => { + it("returns a human-readable label for every state", () => { + const states = ["pending", "confirmed", "dismissed", "deferred"] as const; + for (const state of states) { + const label = formatGroupState(state); + expect(typeof label).toBe("string"); + expect(label.length).toBeGreaterThan(0); + } + }); +}); + +describe("formatTimeBucket", () => { + it("returns a non-empty string", () => { + const result = formatTimeBucket(5_869_717); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("includes the start time in ISO format", () => { + const result = formatTimeBucket(0); + expect(result).toContain("1970"); + }); +}); diff --git a/features/duplicate-transaction-scanner/__tests__/review.test.ts b/features/duplicate-transaction-scanner/__tests__/review.test.ts new file mode 100644 index 0000000..68daaed --- /dev/null +++ b/features/duplicate-transaction-scanner/__tests__/review.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + applyReview, + isTerminalState, + mergeIntoStore, +} from "@/features/duplicate-transaction-scanner/lib/review"; +import { + PENDING_GROUP, +} from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture"; +import type { + CandidateGroup, + ReviewInput, +} from "@/features/duplicate-transaction-scanner/types"; + +const ACTOR_ID = "session:test-actor"; + +function reviewInput( + groupId: string, + action: ReviewInput["action"], + reason?: string, +): ReviewInput { + return { groupId, action, actorId: ACTOR_ID, reason }; +} + +describe("isTerminalState", () => { + it("returns true for confirmed and dismissed", () => { + expect(isTerminalState("confirmed")).toBe(true); + expect(isTerminalState("dismissed")).toBe(true); + }); + + it("returns false for pending and deferred", () => { + expect(isTerminalState("pending")).toBe(false); + expect(isTerminalState("deferred")).toBe(false); + }); +}); + +describe("applyReview", () => { + it("transitions pending → confirmed on confirm", () => { + const result = applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "confirm")); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.state).toBe("confirmed"); + }); + + it("transitions pending → dismissed on dismiss", () => { + const result = applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "dismiss")); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.state).toBe("dismissed"); + }); + + it("transitions pending → deferred on defer", () => { + const result = applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "defer")); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.state).toBe("deferred"); + }); + + it("records reviewedAt and reviewedBy on success", () => { + const result = applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "confirm")); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.reviewedAt).toBeTruthy(); + expect(result.value.reviewedBy).toBe(ACTOR_ID); + }); + + it("stores the optional reason on the group", () => { + const result = applyReview( + PENDING_GROUP, + reviewInput(PENDING_GROUP.id, "dismiss", "false_positive"), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.reviewReason).toBe("false_positive"); + }); + + it("refuses to transition a confirmed group", () => { + const confirmed: CandidateGroup = { ...PENDING_GROUP, state: "confirmed" }; + const result = applyReview(confirmed, reviewInput(PENDING_GROUP.id, "confirm")); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("invalid_transition"); + }); + + it("refuses to transition a dismissed group", () => { + const dismissed: CandidateGroup = { ...PENDING_GROUP, state: "dismissed" }; + const result = applyReview(dismissed, reviewInput(PENDING_GROUP.id, "dismiss")); + expect(result.ok).toBe(false); + }); + + it("allows confirming a deferred group", () => { + const deferred: CandidateGroup = { ...PENDING_GROUP, state: "deferred" }; + const result = applyReview(deferred, reviewInput(PENDING_GROUP.id, "confirm")); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.state).toBe("confirmed"); + }); + + it("allows dismissing a deferred group", () => { + const deferred: CandidateGroup = { ...PENDING_GROUP, state: "deferred" }; + const result = applyReview(deferred, reviewInput(PENDING_GROUP.id, "dismiss")); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.state).toBe("dismissed"); + }); + + it("does not mutate the original group", () => { + const original = { ...PENDING_GROUP }; + applyReview(PENDING_GROUP, reviewInput(PENDING_GROUP.id, "confirm")); + expect(PENDING_GROUP.state).toBe(original.state); + }); +}); + +describe("mergeIntoStore", () => { + it("adds new incoming groups to an empty store", () => { + const result = mergeIntoStore([PENDING_GROUP], []); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(PENDING_GROUP.id); + }); + + it("preserves a confirmed decision from the store", () => { + const confirmed: CandidateGroup = { ...PENDING_GROUP, state: "confirmed" }; + const result = mergeIntoStore([PENDING_GROUP], [confirmed]); + expect(result[0].state).toBe("confirmed"); + }); + + it("preserves a dismissed decision from the store", () => { + const dismissed: CandidateGroup = { ...PENDING_GROUP, state: "dismissed" }; + const result = mergeIntoStore([PENDING_GROUP], [dismissed]); + expect(result[0].state).toBe("dismissed"); + }); + + it("preserves a deferred decision from the store", () => { + const deferred: CandidateGroup = { ...PENDING_GROUP, state: "deferred" }; + const result = mergeIntoStore([PENDING_GROUP], [deferred]); + expect(result[0].state).toBe("deferred"); + }); + + it("retains historical groups not present in the incoming scan", () => { + const historical: CandidateGroup = { + ...PENDING_GROUP, + id: "historical-group", + state: "confirmed", + }; + const result = mergeIntoStore([], [historical]); + expect(result.some((g) => g.id === "historical-group")).toBe(true); + }); + + it("sorts pending groups before deferred groups before resolved groups", () => { + const deferred: CandidateGroup = { + ...PENDING_GROUP, + id: "deferred-1", + state: "deferred", + }; + const confirmed: CandidateGroup = { + ...PENDING_GROUP, + id: "confirmed-1", + state: "confirmed", + }; + const result = mergeIntoStore([PENDING_GROUP, deferred, confirmed], []); + expect(result[0].state).toBe("pending"); + expect(result[1].state).toBe("deferred"); + expect(result[2].state).toBe("confirmed"); + }); +}); diff --git a/features/duplicate-transaction-scanner/__tests__/scanner.test.ts b/features/duplicate-transaction-scanner/__tests__/scanner.test.ts new file mode 100644 index 0000000..d762885 --- /dev/null +++ b/features/duplicate-transaction-scanner/__tests__/scanner.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { withMswHandlers } from "@/core/testing/msw"; +import { scanForDuplicates } from "@/features/duplicate-transaction-scanner/lib/scanner"; +import { + handlers, + notFoundHandler, + rateLimitedHandler, +} from "@/features/duplicate-transaction-scanner/msw/handlers"; +import { + HASH_A1, + HASH_A2, + HASH_B1, +} from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture"; + +const server = withMswHandlers(...handlers); + +const TESTNET_BASE = "https://horizon-testnet.stellar.org"; + +describe("scanForDuplicates", () => { + it("returns one duplicate group for two identical transactions", async () => { + const result = await scanForDuplicates( + { hashes: [HASH_A1, HASH_A2], network: "testnet" }, + TESTNET_BASE, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.scannedCount).toBe(2); + expect(result.value.groupCount).toBe(1); + expect(result.value.groups).toHaveLength(1); + expect(result.value.groups[0].transactions).toHaveLength(2); + expect(result.value.groups[0].state).toBe("pending"); + }); + + it("returns no groups when there are no duplicates", async () => { + const result = await scanForDuplicates( + { hashes: [HASH_A1, HASH_B1], network: "testnet" }, + TESTNET_BASE, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.groupCount).toBe(0); + expect(result.value.scannedCount).toBe(2); + }); + + it("returns not_found when a hash is missing from Horizon", async () => { + server.use(notFoundHandler); + const result = await scanForDuplicates( + { hashes: [HASH_A1], network: "testnet" }, + TESTNET_BASE, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("not_found"); + }); + + it("returns rate_limited on HTTP 429", async () => { + server.use(rateLimitedHandler); + const result = await scanForDuplicates( + { hashes: [HASH_A1], network: "testnet" }, + TESTNET_BASE, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("rate_limited"); + }); + + it("includes the network and scannedAt in the result", async () => { + const result = await scanForDuplicates( + { hashes: [HASH_A1, HASH_A2], network: "testnet" }, + TESTNET_BASE, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.network).toBe("testnet"); + expect(result.value.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("duplicate groups have deterministic ids (same input → same group id)", async () => { + const r1 = await scanForDuplicates( + { hashes: [HASH_A1, HASH_A2], network: "testnet" }, + TESTNET_BASE, + ); + const r2 = await scanForDuplicates( + { hashes: [HASH_A1, HASH_A2], network: "testnet" }, + TESTNET_BASE, + ); + expect(r1.ok && r2.ok).toBe(true); + if (!r1.ok || !r2.ok) return; + expect(r1.value.groups[0].id).toBe(r2.value.groups[0].id); + }); +}); diff --git a/features/duplicate-transaction-scanner/__tests__/schema.test.ts b/features/duplicate-transaction-scanner/__tests__/schema.test.ts new file mode 100644 index 0000000..c7d9fa2 --- /dev/null +++ b/features/duplicate-transaction-scanner/__tests__/schema.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { MAX_HASHES, parseDuplicateScanInput } from "@/features/duplicate-transaction-scanner/schema"; +import { HASH_A1, HASH_A2 } from "@/features/duplicate-transaction-scanner/fixtures/scanner.fixture"; + +describe("parseDuplicateScanInput", () => { + it("rejects empty input", () => { + expect(parseDuplicateScanInput("", "testnet")).toEqual({ ok: false, code: "empty_input" }); + expect(parseDuplicateScanInput(" \n\n ", "testnet")).toEqual({ ok: false, code: "empty_input" }); + }); + + it("accepts a single valid hash", () => { + const result = parseDuplicateScanInput(HASH_A1, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.hashes).toEqual([HASH_A1]); + expect(result.value.network).toBe("testnet"); + }); + + it("accepts multiple hashes separated by newlines", () => { + const result = parseDuplicateScanInput(`${HASH_A1}\n${HASH_A2}`, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.hashes).toHaveLength(2); + }); + + it("normalises hashes to lower case", () => { + const upper = HASH_A1.toUpperCase(); + const result = parseDuplicateScanInput(upper, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.hashes[0]).toBe(HASH_A1); + }); + + it("deduplicates hashes submitted twice", () => { + const result = parseDuplicateScanInput(`${HASH_A1}\n${HASH_A1}`, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.hashes).toHaveLength(1); + }); + + it("rejects a non-hex string", () => { + const bad = "z".repeat(64); + expect(parseDuplicateScanInput(bad, "testnet")).toEqual({ ok: false, code: "invalid_hash" }); + }); + + it("rejects a hash that is too short", () => { + const short = "a".repeat(63); + expect(parseDuplicateScanInput(short, "testnet")).toEqual({ ok: false, code: "invalid_hash" }); + }); + + it("rejects a hash that is too long", () => { + const long = "a".repeat(65); + expect(parseDuplicateScanInput(long, "testnet")).toEqual({ ok: false, code: "invalid_hash" }); + }); + + it(`rejects more than ${MAX_HASHES} hashes`, () => { + const hashes = Array.from({ length: MAX_HASHES + 1 }, (_, i) => + String(i).padStart(2, "0").padEnd(64, "0"), + ).join("\n"); + expect(parseDuplicateScanInput(hashes, "testnet")).toEqual({ + ok: false, + code: "too_many_hashes", + }); + }); + + it(`accepts exactly ${MAX_HASHES} hashes`, () => { + const hashes = Array.from({ length: MAX_HASHES }, (_, i) => + String(i).padStart(2, "0").padEnd(64, "0"), + ).join("\n"); + const result = parseDuplicateScanInput(hashes, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.hashes).toHaveLength(MAX_HASHES); + }); + + it("strips surrounding whitespace from each line", () => { + const result = parseDuplicateScanInput(` ${HASH_A1} \n ${HASH_A2} `, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.hashes).toEqual([HASH_A1, HASH_A2]); + }); +}); diff --git a/features/duplicate-transaction-scanner/components/DuplicateScannerEmptyState.tsx b/features/duplicate-transaction-scanner/components/DuplicateScannerEmptyState.tsx new file mode 100644 index 0000000..e47e4ac --- /dev/null +++ b/features/duplicate-transaction-scanner/components/DuplicateScannerEmptyState.tsx @@ -0,0 +1,13 @@ +import { GitCompare } from "lucide-react"; +import { EmptyState } from "@/core/ui"; +import { copy } from "@/features/duplicate-transaction-scanner/copy"; + +export function DuplicateScannerEmptyState() { + return ( + + ); +} diff --git a/features/duplicate-transaction-scanner/components/DuplicateScannerForm.tsx b/features/duplicate-transaction-scanner/components/DuplicateScannerForm.tsx new file mode 100644 index 0000000..c3eca21 --- /dev/null +++ b/features/duplicate-transaction-scanner/components/DuplicateScannerForm.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import { Button, Field, Textarea } from "@/core/ui"; +import { copy } from "@/features/duplicate-transaction-scanner/copy"; + +interface DuplicateScannerFormProps { + onScan: (rawHashes: string) => void; + isLoading: boolean; +} + +export function DuplicateScannerForm({ onScan, isLoading }: DuplicateScannerFormProps) { + const [raw, setRaw] = useState(""); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + onScan(raw); + } + + return ( +
+ + {({ inputId, describedBy }) => ( +