Files
aml/pkg/sanctions/match_test.go
T
zeekayandHanzo Dev 07e69e3ee2 luxfi/aml — real-time AML/CFT transaction monitoring
A Go AML engine: a configurable rules DSL over expr-lang with weight-of-evidence
scoring, sanctions screening against OFAC/UN/EU/HMT, case management, and scored
alerting. Single binary, embedded SQLite per org, embedded UI.

Surface (all /v1, org-scoped):
  /v1/aml/transactions        ingest and score
  /v1/aml/rules  /rules/test  the rules DSL and its dry-run
  /v1/aml/sanctions/search    list screening
  /v1/aml/cases               investigation workflow
  /v1/aml/health

Build: make build produces one static binary with the UI embedded (embedui tag);
bare Go tooling needs no Node. Third-party license and notice texts are generated
from the module graph the linker actually walks and ship beside every artifact.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 22:51:25 -07:00

169 lines
4.4 KiB
Go

package sanctions
import (
"math"
"testing"
)
func TestJaroWinklerExact(t *testing.T) {
score := JaroWinkler("john smith", "john smith")
if score != 1.0 {
t.Errorf("exact match: got %f, want 1.0", score)
}
}
func TestJaroWinklerEmpty(t *testing.T) {
if JaroWinkler("", "abc") != 0.0 {
t.Error("empty s1 should return 0")
}
if JaroWinkler("abc", "") != 0.0 {
t.Error("empty s2 should return 0")
}
}
func TestJaroWinklerSimilar(t *testing.T) {
score := JaroWinkler("john smith", "jon smith")
if score < 0.9 {
t.Errorf("similar names: got %f, want >= 0.9", score)
}
}
func TestJaroWinklerDifferent(t *testing.T) {
score := JaroWinkler("john smith", "alice jones")
if score > 0.7 {
t.Errorf("different names: got %f, want < 0.7", score)
}
}
func TestJaroWinklerCaseInsensitive(t *testing.T) {
s1 := JaroWinkler("John Smith", "john smith")
s2 := JaroWinkler("JOHN SMITH", "john smith")
if s1 != 1.0 || s2 != 1.0 {
t.Errorf("case insensitive: got %f, %f — want 1.0 for both", s1, s2)
}
}
func TestJaroWinklerCommonPrefix(t *testing.T) {
// Winkler bonus rewards common prefix.
withPrefix := JaroWinkler("johnson", "johnsn")
noPrefix := JaroWinkler("nohnson", "noahson")
if withPrefix <= noPrefix {
t.Errorf("common prefix should score higher: withPrefix=%f, noPrefix=%f", withPrefix, noPrefix)
}
}
func TestNormalize(t *testing.T) {
tests := []struct {
in, want string
}{
{"John Smith", "johnsmith"},
{"O'Brien", "obrien"},
{"Al-Rashid", "alrashid"},
{" spaces ", "spaces"},
{"Números123", "numeros123"},
}
for _, tt := range tests {
got := normalize(tt.in)
if got != tt.want {
t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTokenMatch(t *testing.T) {
score := TokenMatch("John Smith", "Smith John")
if score < 0.95 {
t.Errorf("reordered tokens: got %f, want >= 0.95", score)
}
}
func TestTokenMatchPartial(t *testing.T) {
score := TokenMatch("John A Smith", "John Smith")
// "John" matches perfectly, "A" doesn't match well, "Smith" matches perfectly.
if score < 0.6 {
t.Errorf("partial token match: got %f, want >= 0.6", score)
}
}
func TestTokenMatchEmpty(t *testing.T) {
if TokenMatch("", "anything") != 0.0 {
t.Error("empty input should return 0")
}
if TokenMatch("anything", "") != 0.0 {
t.Error("empty candidate should return 0")
}
}
func approxEqual(a, b, tolerance float64) bool {
return math.Abs(a-b) < tolerance
}
func TestJaroWinklerSymmetric(t *testing.T) {
s1 := JaroWinkler("martha", "marhta")
s2 := JaroWinkler("marhta", "martha")
if !approxEqual(s1, s2, 0.001) {
t.Errorf("JaroWinkler should be symmetric: %f vs %f", s1, s2)
}
}
func TestJaroWinklerKnownValue(t *testing.T) {
// Classic Jaro-Winkler example: MARTHA/MARHTA.
score := JaroWinkler("martha", "marhta")
if score < 0.96 {
t.Errorf("MARTHA/MARHTA: got %f, want >= 0.96", score)
}
}
// TestMatchCyrillicHomoglyph (RED-10) verifies that Cyrillic homoglyphs
// are transliterated before matching, catching evasion attempts.
func TestMatchCyrillicHomoglyph(t *testing.T) {
// "Ким Йонг Ун" (Cyrillic) vs "Kim Jong Un" (Latin)
score := TokenMatch("Ким Йонг Ун", "Kim Jong Un")
if score < 0.7 {
t.Errorf("RED-10 Cyrillic homoglyph: score=%f, want >= 0.7", score)
}
}
// TestMatchReversedName (RED-10) verifies that reversed name order still matches.
func TestMatchReversedName(t *testing.T) {
score := TokenMatch("Jong Un Kim", "Kim Jong Un")
if score < 0.8 {
t.Errorf("RED-10 reversed name: score=%f, want >= 0.8", score)
}
}
// TestMatchTransliteration (RED-10) verifies transliteration of Cyrillic names.
func TestMatchTransliteration(t *testing.T) {
// Common Cyrillic→Latin transliteration test.
score := TokenMatch("Путин Владимир", "Putin Vladimir")
if score < 0.5 {
t.Errorf("RED-10 transliteration: score=%f, want >= 0.5", score)
}
}
// TestNormalizeNFKD (RED-10) verifies NFKD normalization handles fullwidth chars.
func TestNormalizeNFKD(t *testing.T) {
// Fullwidth "John" should normalize to "john".
got := normalize("John")
if got != "john" {
t.Errorf("normalize fullwidth = %q, want %q", got, "john")
}
}
func TestSoundex(t *testing.T) {
tests := []struct {
in, want string
}{
{"Robert", "R163"},
{"Rupert", "R163"},
{"Smith", "S530"},
{"Smyth", "S530"},
}
for _, tt := range tests {
got := soundex(tt.in)
if got != tt.want {
t.Errorf("soundex(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}