Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions features/duplicate-transaction-scanner/__tests__/fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions features/duplicate-transaction-scanner/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
165 changes: 165 additions & 0 deletions features/duplicate-transaction-scanner/__tests__/review.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading