diff --git a/README.md b/README.md
index 9b9f8bc..1e3db42 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ directly under `output/` instead, with the equivalent depth in the article's own
| [`broker-comparison/`](broker-comparison/README.md) | [Kafka vs RabbitMQ vs Pulsar for Java Teams: A Decision Framework with Benchmarks](https://ankurm.com/kafka-vs-rabbitmq-vs-pulsar-java-decision-framework/) | The three brokers measured side by side on ordering, replay, consumer scaling and operational footprint, ending in a decision table where every row has a transcript behind it |
| [`saga/`](saga/README.md) | Saga Pattern in Spring Boot: Orchestration vs Choreography with Kafka | The same order/payment/inventory saga built twice against a real broker — choreography and orchestration — driven through the same inventory failure so the compensating transaction can be compared side by side, plus a real duplicate-delivery test for the idempotent-consumer problem |
| [`share-groups/`](share-groups/README.md) | [Queues for Kafka: Share Groups with Spring for Apache Kafka 4](https://ankurm.com/kafka-share-groups-spring-kafka-4-queues-for-kafka/) | Share groups against a real Kafka 4.3.1 broker: six consumers on two partitions, the acquire mode that decides whether they really share, the three acknowledgement types, a group that starts at the end of the topic, and the ordering you give up |
+| [`kafka-streams/`](kafka-streams/README.md) | Kafka Streams with Spring Boot 4: Real-Time Fraud Detection with Vector Similarity | A fraud topology tested entirely under `TopologyTestDriver`: cosine-similarity scoring, a 5-minute windowed count that alerts exactly once, and a KTable blocklist join with a tombstone, plus the Spring wiring that needs no broker to load |
The brokers make an instructive set. Kafka's consumer holds an offset and the broker remembers
nothing about individual records; RabbitMQ's broker owns the message until it is acknowledged and
diff --git a/kafka-streams/README.md b/kafka-streams/README.md
new file mode 100644
index 0000000..85a9219
--- /dev/null
+++ b/kafka-streams/README.md
@@ -0,0 +1,43 @@
+# kafka-streams
+
+Companion code for *Kafka Streams with Spring Boot 4: Real-Time Fraud Detection with Vector
+Similarity* on [ankurm.com](https://ankurm.com). The whole topology runs under
+`TopologyTestDriver`, so **no Kafka broker is needed** to run anything here.
+
+| Stack | Version |
+|---|---|
+| JDK | 25 (Temurin 25.0.4.1+1) |
+| Spring Boot | 4.1.1 |
+| Spring for Apache Kafka | 4.1.1 |
+| kafka-streams / kafka-streams-test-utils | 4.2.1 (what Boot 4.1.1 manages) |
+
+## Run it
+
+```bash
+./scripts/run-all.sh # runs the tests, rewrites output/*.txt
+```
+
+## What is where
+
+| File | Role |
+|---|---|
+| `FraudTopology` | The topology as plain Kafka Streams code: similarity scoring, 5-minute velocity count, blocklist KTable join |
+| `Embedder` | A hand-built 6-number feature vector and cosine similarity (not a learned model) |
+| `FraudStreamsConfig` | The only Spring wiring: `@EnableKafkaStreams` plus handing the `StreamsBuilder` to the topology |
+| `FraudTopologyTest` | Five tests; each one writes the matching `output/NN-*.txt` |
+| `ContextLoadsTest` | Loads the Spring context with `auto-startup=false` to show the wiring without a broker |
+
+## Captured output
+
+Every file in `output/` is written by a test that also asserts it.
+
+| File | Shows |
+|---|---|
+| `01-similarity-scores.txt` | Scores for five sample transactions against the 0.98 threshold |
+| `02-similarity-alerts.txt` | Only the fraud-shaped transaction produces an alert |
+| `03-velocity-window.txt` | Six transactions in two minutes give one alert; the next window starts from one |
+| `04-blocklist-table-join.txt` | Join before blocking, after blocking, and after a tombstone |
+| `05-topology-description.txt` | `Topology.describe()` of the whole graph |
+| `06-spring-context.txt` | The Spring factory bean is wired and not running |
+
+The article carries the explanations; there is deliberately no `docs/` folder.
diff --git a/kafka-streams/output/01-similarity-scores.txt b/kafka-streams/output/01-similarity-scores.txt
new file mode 100644
index 0000000..2ebb48a
--- /dev/null
+++ b/kafka-streams/output/01-similarity-scores.txt
@@ -0,0 +1,6 @@
+c1,4700.0,3,crypto score=1.0000
+c1,3000.0,2,electronics score=1.0000
+c1,45.0,18,grocery score=-0.1185
+c1,120.0,13,electronics score=0.1387
+c1,2500.0,14,crypto score=0.2136
+threshold: 0.98
diff --git a/kafka-streams/output/02-similarity-alerts.txt b/kafka-streams/output/02-similarity-alerts.txt
new file mode 100644
index 0000000..b0458fe
--- /dev/null
+++ b/kafka-streams/output/02-similarity-alerts.txt
@@ -0,0 +1,2 @@
+alerts <- c1 SIMILAR:1.0000:c1,4700.0,3,crypto
+alerts emitted: 1
diff --git a/kafka-streams/output/03-velocity-window.txt b/kafka-streams/output/03-velocity-window.txt
new file mode 100644
index 0000000..738a02d
--- /dev/null
+++ b/kafka-streams/output/03-velocity-window.txt
@@ -0,0 +1,5 @@
+alerts <- c9 VELOCITY:4 in 5m
+alerts emitted for 6 transactions in 2 minutes: 1
+alerts after one more transaction 10 minutes later: 0
+state store window 2026-10-14T10:00:00Z count=6
+state store window 2026-10-14T10:10:00Z count=1
diff --git a/kafka-streams/output/04-blocklist-table-join.txt b/kafka-streams/output/04-blocklist-table-join.txt
new file mode 100644
index 0000000..2975916
--- /dev/null
+++ b/kafka-streams/output/04-blocklist-table-join.txt
@@ -0,0 +1,3 @@
+before blocking, alerts: 0
+alerts <- c5 BLOCKED:reported stolen
+after the tombstone, alerts: 0
diff --git a/kafka-streams/output/05-topology-description.txt b/kafka-streams/output/05-topology-description.txt
new file mode 100644
index 0000000..7cdb895
--- /dev/null
+++ b/kafka-streams/output/05-topology-description.txt
@@ -0,0 +1,39 @@
+Topologies:
+ Sub-topology: 0
+ Source: KSTREAM-SOURCE-0000000000 (topics: [transactions])
+ --> KSTREAM-AGGREGATE-0000000005, KSTREAM-JOIN-0000000013, KSTREAM-MAPVALUES-0000000001
+ Processor: KSTREAM-AGGREGATE-0000000005 (stores: [txn-count-5m])
+ --> KTABLE-TOSTREAM-0000000006
+ <-- KSTREAM-SOURCE-0000000000
+ Processor: KSTREAM-MAPVALUES-0000000001 (stores: [])
+ --> score
+ <-- KSTREAM-SOURCE-0000000000
+ Processor: KTABLE-TOSTREAM-0000000006 (stores: [])
+ --> KSTREAM-FILTER-0000000007
+ <-- KSTREAM-AGGREGATE-0000000005
+ Processor: KSTREAM-FILTER-0000000007 (stores: [])
+ --> KSTREAM-MAP-0000000008
+ <-- KTABLE-TOSTREAM-0000000006
+ Processor: score (stores: [])
+ --> KSTREAM-FILTER-0000000003
+ <-- KSTREAM-MAPVALUES-0000000001
+ Processor: KSTREAM-FILTER-0000000003 (stores: [])
+ --> KSTREAM-SINK-0000000004
+ <-- score
+ Processor: KSTREAM-JOIN-0000000013 (stores: [blocked-cards-STATE-STORE-0000000010])
+ --> KSTREAM-SINK-0000000014
+ <-- KSTREAM-SOURCE-0000000000
+ Processor: KSTREAM-MAP-0000000008 (stores: [])
+ --> KSTREAM-SINK-0000000009
+ <-- KSTREAM-FILTER-0000000007
+ Source: KSTREAM-SOURCE-0000000011 (topics: [blocked-cards])
+ --> KTABLE-SOURCE-0000000012
+ Sink: KSTREAM-SINK-0000000004 (topic: alerts)
+ <-- KSTREAM-FILTER-0000000003
+ Sink: KSTREAM-SINK-0000000009 (topic: alerts)
+ <-- KSTREAM-MAP-0000000008
+ Sink: KSTREAM-SINK-0000000014 (topic: alerts)
+ <-- KSTREAM-JOIN-0000000013
+ Processor: KTABLE-SOURCE-0000000012 (stores: [blocked-cards-STATE-STORE-0000000010])
+ --> none
+ <-- KSTREAM-SOURCE-0000000011
diff --git a/kafka-streams/output/06-spring-context.txt b/kafka-streams/output/06-spring-context.txt
new file mode 100644
index 0000000..a1fd290
--- /dev/null
+++ b/kafka-streams/output/06-spring-context.txt
@@ -0,0 +1,3 @@
+StreamsBuilderFactoryBean present: true
+auto-startup: false
+running: false
diff --git a/kafka-streams/pom.xml b/kafka-streams/pom.xml
new file mode 100644
index 0000000..ae74809
--- /dev/null
+++ b/kafka-streams/pom.xml
@@ -0,0 +1,56 @@
+
+ 4.0.0
+
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ kafka-streams-fraud
+ 1.0
+ jar
+
+
+ 25
+ UTF-8
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-kafka
+
+
+ org.apache.kafka
+ kafka-streams
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.apache.kafka
+ kafka-streams-test-utils
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/kafka-streams/scripts/run-all.sh b/kafka-streams/scripts/run-all.sh
new file mode 100755
index 0000000..1b47ac3
--- /dev/null
+++ b/kafka-streams/scripts/run-all.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# Regenerates every file under output/ by running the test suite. No broker needed.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+rm -f output/*.txt
+mvn -B -q test
+ls output
diff --git a/kafka-streams/src/main/java/com/ankurm/streams/Embedder.java b/kafka-streams/src/main/java/com/ankurm/streams/Embedder.java
new file mode 100644
index 0000000..c95a22d
--- /dev/null
+++ b/kafka-streams/src/main/java/com/ankurm/streams/Embedder.java
@@ -0,0 +1,51 @@
+package com.ankurm.streams;
+
+import java.util.List;
+
+/**
+ * Turns a transaction into a vector and scores it by cosine similarity to known-fraud vectors.
+ *
+ *
This is a hand-built feature vector, not a learned embedding: [log amount, sin(hour),
+ * cos(hour), is-crypto, is-electronics, is-grocery]. The topology around it would be identical if
+ * {@link #embed} called an embedding model; only this one method would change.
+ */
+public final class Embedder {
+
+ private Embedder() {}
+
+ /** Prototypes of confirmed fraud: large, late at night, in risky categories. */
+ static final List FRAUD_PROTOTYPES = List.of(
+ embed(new Txn("proto", 4800, 3, "crypto")),
+ embed(new Txn("proto", 2900, 2, "electronics")));
+
+ public static double[] embed(Txn t) {
+ double angle = 2 * Math.PI * t.hour() / 24.0;
+ return new double[] {
+ Math.log10(Math.max(1, t.amount())) / 5.0,
+ Math.sin(angle),
+ Math.cos(angle),
+ "crypto".equals(t.category()) ? 1 : 0,
+ "electronics".equals(t.category()) ? 1 : 0,
+ "grocery".equals(t.category()) ? 1 : 0 };
+ }
+
+ public static double cosine(double[] a, double[] b) {
+ double dot = 0, na = 0, nb = 0;
+ for (int i = 0; i < a.length; i++) {
+ dot += a[i] * b[i];
+ na += a[i] * a[i];
+ nb += b[i] * b[i];
+ }
+ return (na == 0 || nb == 0) ? 0 : dot / (Math.sqrt(na) * Math.sqrt(nb));
+ }
+
+ /** Highest similarity to any fraud prototype, in [-1, 1]. */
+ public static double fraudScore(Txn t) {
+ double[] v = embed(t);
+ double best = -1;
+ for (double[] p : FRAUD_PROTOTYPES) {
+ best = Math.max(best, cosine(v, p));
+ }
+ return best;
+ }
+}
diff --git a/kafka-streams/src/main/java/com/ankurm/streams/FraudStreamsConfig.java b/kafka-streams/src/main/java/com/ankurm/streams/FraudStreamsConfig.java
new file mode 100644
index 0000000..828d254
--- /dev/null
+++ b/kafka-streams/src/main/java/com/ankurm/streams/FraudStreamsConfig.java
@@ -0,0 +1,20 @@
+package com.ankurm.streams;
+
+import org.apache.kafka.streams.StreamsBuilder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafkaStreams;
+
+/**
+ * The Spring side is only this: {@code @EnableKafkaStreams} supplies a {@link StreamsBuilder}
+ * bean, and this method hands it to the plain-Java topology before the streams instance starts.
+ */
+@Configuration
+@EnableKafkaStreams
+public class FraudStreamsConfig {
+
+ @Autowired
+ public void wire(StreamsBuilder builder) {
+ FraudTopology.wire(builder);
+ }
+}
diff --git a/kafka-streams/src/main/java/com/ankurm/streams/FraudTopology.java b/kafka-streams/src/main/java/com/ankurm/streams/FraudTopology.java
new file mode 100644
index 0000000..755be44
--- /dev/null
+++ b/kafka-streams/src/main/java/com/ankurm/streams/FraudTopology.java
@@ -0,0 +1,64 @@
+package com.ankurm.streams;
+
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.kstream.Consumed;
+import org.apache.kafka.streams.kstream.Grouped;
+import org.apache.kafka.streams.kstream.KStream;
+import org.apache.kafka.streams.kstream.KTable;
+import org.apache.kafka.streams.kstream.Materialized;
+import org.apache.kafka.streams.kstream.Produced;
+import org.apache.kafka.streams.kstream.TimeWindows;
+
+import java.time.Duration;
+
+/**
+ * The topology, as plain Kafka Streams code with no Spring in it. That is deliberate: a topology
+ * that only needs a {@link StreamsBuilder} can be tested with TopologyTestDriver in milliseconds,
+ * with no broker.
+ *
+ *
+ * transactions --+--> mapValues(score) --filter(score >= 0.98)--------> alerts "SIMILAR:..."
+ * |
+ * +--> groupByKey --> 5-minute tumbling count (store txn-count-5m)
+ * | --filter(count == 4)------------------------> alerts "VELOCITY:..."
+ * |
+ * +--> join(blocked-cards KTable)-----------------------> alerts "BLOCKED:..."
+ *
+ */
+public final class FraudTopology {
+
+ public static final String IN = "transactions";
+ public static final String BLOCKED = "blocked-cards";
+ public static final String OUT = "alerts";
+ public static final String COUNT_STORE = "txn-count-5m";
+ public static final double SIMILARITY_THRESHOLD = 0.98;
+ public static final long VELOCITY_LIMIT = 4;
+
+ private FraudTopology() {}
+
+ public static void wire(StreamsBuilder b) {
+ KStream raw = b.stream(IN, Consumed.with(Serdes.String(), Serdes.String()));
+
+ // 1. Similarity: score every transaction, keep the ones close to a known-fraud vector.
+ raw.mapValues(v -> Txn.decode(v))
+ .mapValues(t -> "SIMILAR:" + String.format("%.4f", Embedder.fraudScore(t)) + ":" + t.encode(),
+ org.apache.kafka.streams.kstream.Named.as("score"))
+ .filter((k, v) -> Double.parseDouble(v.split(":")[1]) >= SIMILARITY_THRESHOLD)
+ .to(OUT, Produced.with(Serdes.String(), Serdes.String()));
+
+ // 2. Velocity: count per card in 5-minute tumbling windows, alert on exactly the 4th.
+ raw.groupByKey(Grouped.with(Serdes.String(), Serdes.String()))
+ .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
+ .count(Materialized.as(COUNT_STORE))
+ .toStream()
+ .filter((w, c) -> c == VELOCITY_LIMIT)
+ .map((w, c) -> org.apache.kafka.streams.KeyValue.pair(w.key(), "VELOCITY:" + c + " in 5m"))
+ .to(OUT, Produced.with(Serdes.String(), Serdes.String()));
+
+ // 3. Blocklist: a KTable of blocked cards, joined to each transaction as it arrives.
+ KTable blocked = b.table(BLOCKED, Consumed.with(Serdes.String(), Serdes.String()));
+ raw.join(blocked, (txn, reason) -> "BLOCKED:" + reason)
+ .to(OUT, Produced.with(Serdes.String(), Serdes.String()));
+ }
+}
diff --git a/kafka-streams/src/main/java/com/ankurm/streams/StreamsApplication.java b/kafka-streams/src/main/java/com/ankurm/streams/StreamsApplication.java
new file mode 100644
index 0000000..5645070
--- /dev/null
+++ b/kafka-streams/src/main/java/com/ankurm/streams/StreamsApplication.java
@@ -0,0 +1,11 @@
+package com.ankurm.streams;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class StreamsApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(StreamsApplication.class, args);
+ }
+}
diff --git a/kafka-streams/src/main/java/com/ankurm/streams/Txn.java b/kafka-streams/src/main/java/com/ankurm/streams/Txn.java
new file mode 100644
index 0000000..e5ff1c3
--- /dev/null
+++ b/kafka-streams/src/main/java/com/ankurm/streams/Txn.java
@@ -0,0 +1,15 @@
+package com.ankurm.streams;
+
+/** A card transaction. */
+public record Txn(String card, double amount, int hour, String category) {
+
+ /** Wire format: {@code card,amount,hour,category}. Plain text keeps the serde trivial to read. */
+ public String encode() {
+ return card + "," + amount + "," + hour + "," + category;
+ }
+
+ public static Txn decode(String s) {
+ String[] p = s.split(",", 4);
+ return new Txn(p[0], Double.parseDouble(p[1]), Integer.parseInt(p[2]), p[3]);
+ }
+}
diff --git a/kafka-streams/src/main/resources/application.properties b/kafka-streams/src/main/resources/application.properties
new file mode 100644
index 0000000..e571b0d
--- /dev/null
+++ b/kafka-streams/src/main/resources/application.properties
@@ -0,0 +1,4 @@
+spring.kafka.bootstrap-servers=localhost:9092
+spring.kafka.streams.application-id=fraud-streams
+# Off by default here so the context can load without a broker; set true when you have one.
+spring.kafka.streams.auto-startup=false
diff --git a/kafka-streams/src/test/java/com/ankurm/streams/ContextLoadsTest.java b/kafka-streams/src/test/java/com/ankurm/streams/ContextLoadsTest.java
new file mode 100644
index 0000000..8924eb6
--- /dev/null
+++ b/kafka-streams/src/test/java/com/ankurm/streams/ContextLoadsTest.java
@@ -0,0 +1,27 @@
+package com.ankurm.streams;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.ApplicationContext;
+import org.springframework.kafka.config.StreamsBuilderFactoryBean;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Proves the Spring wiring: the factory bean exists and holds a topology, without a broker. */
+@SpringBootTest
+class ContextLoadsTest {
+
+ @Autowired ApplicationContext ctx;
+
+ @Test
+ void streamsFactoryBeanIsWiredAndNotStarted() throws Exception {
+ var t = new Transcript("06-spring-context.txt");
+ var fb = ctx.getBean(StreamsBuilderFactoryBean.class);
+ t.line("StreamsBuilderFactoryBean present: " + (fb != null));
+ t.line("auto-startup: " + fb.isAutoStartup());
+ t.line("running: " + fb.isRunning());
+ t.write();
+ assertFalse(fb.isRunning());
+ }
+}
diff --git a/kafka-streams/src/test/java/com/ankurm/streams/FraudTopologyTest.java b/kafka-streams/src/test/java/com/ankurm/streams/FraudTopologyTest.java
new file mode 100644
index 0000000..6afc3d0
--- /dev/null
+++ b/kafka-streams/src/test/java/com/ankurm/streams/FraudTopologyTest.java
@@ -0,0 +1,140 @@
+package com.ankurm.streams;
+
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.TestInputTopic;
+import org.apache.kafka.streams.TestOutputTopic;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.TopologyTestDriver;
+import org.apache.kafka.streams.state.WindowStore;
+import org.apache.kafka.streams.state.KeyValueIterator;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class FraudTopologyTest {
+
+ private TopologyTestDriver driver;
+ private TestInputTopic txns;
+ private TestInputTopic blocked;
+ private TestOutputTopic alerts;
+ private Topology topology;
+
+ @BeforeEach
+ void setUp() {
+ StreamsBuilder b = new StreamsBuilder();
+ FraudTopology.wire(b);
+ topology = b.build();
+ Properties p = new Properties();
+ p.put(StreamsConfig.APPLICATION_ID_CONFIG, "fraud-test");
+ p.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "dummy:1");
+ driver = new TopologyTestDriver(topology, p, Instant.parse("2026-10-14T10:00:00Z"));
+ txns = driver.createInputTopic(FraudTopology.IN, new StringSerializer(), new StringSerializer());
+ blocked = driver.createInputTopic(FraudTopology.BLOCKED, new StringSerializer(), new StringSerializer());
+ alerts = driver.createOutputTopic(FraudTopology.OUT, new StringDeserializer(), new StringDeserializer());
+ }
+
+ @AfterEach
+ void tearDown() { driver.close(); }
+
+ @Test
+ void similarityScoresSeparateFraudShapedFromOrdinary() throws Exception {
+ var t = new Transcript("01-similarity-scores.txt");
+ var samples = List.of(
+ new Txn("c1", 4700, 3, "crypto"),
+ new Txn("c1", 3000, 2, "electronics"),
+ new Txn("c1", 45, 18, "grocery"),
+ new Txn("c1", 120, 13, "electronics"),
+ new Txn("c1", 2500, 14, "crypto"));
+ for (Txn x : samples) {
+ t.line(String.format("%-40s score=%.4f", x.encode(), Embedder.fraudScore(x)));
+ }
+ t.line("threshold: " + FraudTopology.SIMILARITY_THRESHOLD);
+ t.write();
+
+ assertTrue(Embedder.fraudScore(samples.get(0)) >= FraudTopology.SIMILARITY_THRESHOLD);
+ assertTrue(Embedder.fraudScore(samples.get(1)) >= FraudTopology.SIMILARITY_THRESHOLD);
+ assertTrue(Embedder.fraudScore(samples.get(2)) < FraudTopology.SIMILARITY_THRESHOLD);
+ }
+
+ @Test
+ void similarTransactionsRaiseAlertsOrdinaryOnesDoNot() throws Exception {
+ var t = new Transcript("02-similarity-alerts.txt");
+ txns.pipeInput("c1", new Txn("c1", 4700, 3, "crypto").encode());
+ txns.pipeInput("c2", new Txn("c2", 45, 18, "grocery").encode());
+ var out = alerts.readKeyValuesToList();
+ out.forEach(kv -> t.line("alerts <- " + kv.key + " " + kv.value));
+ t.line("alerts emitted: " + out.size());
+ t.write();
+
+ assertEquals(1, out.size());
+ assertEquals("c1", out.get(0).key);
+ assertTrue(out.get(0).value.startsWith("SIMILAR:"));
+ }
+
+ @Test
+ void velocityAlertFiresOnTheFourthTransactionInAFiveMinuteWindowOnly() throws Exception {
+ var t = new Transcript("03-velocity-window.txt");
+ Instant start = Instant.parse("2026-10-14T10:00:30Z");
+ for (int i = 0; i < 6; i++) {
+ txns.pipeInput("c9", new Txn("c9", 20, 12, "grocery").encode(), start.plusSeconds(i * 20L));
+ }
+ var out = alerts.readKeyValuesToList();
+ out.forEach(kv -> t.line("alerts <- " + kv.key + " " + kv.value));
+ t.line("alerts emitted for 6 transactions in 2 minutes: " + out.size());
+
+ // The same count in the next window starts again from one.
+ txns.pipeInput("c9", new Txn("c9", 20, 12, "grocery").encode(), start.plusSeconds(600));
+ t.line("alerts after one more transaction 10 minutes later: " + alerts.readKeyValuesToList().size());
+
+ WindowStore store = driver.getWindowStore(FraudTopology.COUNT_STORE);
+ try (KeyValueIterator, Long> it =
+ store.fetchAll(Instant.parse("2026-10-14T09:00:00Z"), Instant.parse("2026-10-14T11:00:00Z"))) {
+ while (it.hasNext()) {
+ var kv = it.next();
+ t.line("state store window " + kv.key.window().startTime() + " count=" + kv.value);
+ }
+ }
+ t.write();
+
+ assertEquals(1, out.size());
+ assertEquals("VELOCITY:4 in 5m", out.get(0).value);
+ }
+
+ @Test
+ void blocklistTableJoinsEachTransactionAsItArrives() throws Exception {
+ var t = new Transcript("04-blocklist-table-join.txt");
+ txns.pipeInput("c5", new Txn("c5", 15, 12, "grocery").encode());
+ t.line("before blocking, alerts: " + alerts.readKeyValuesToList().size());
+
+ blocked.pipeInput("c5", "reported stolen");
+ txns.pipeInput("c5", new Txn("c5", 15, 12, "grocery").encode());
+ var out = alerts.readKeyValuesToList();
+ out.forEach(kv -> t.line("alerts <- " + kv.key + " " + kv.value));
+
+ blocked.pipeInput("c5", (String) null); // tombstone removes the card from the table
+ txns.pipeInput("c5", new Txn("c5", 15, 12, "grocery").encode());
+ t.line("after the tombstone, alerts: " + alerts.readKeyValuesToList().size());
+ t.write();
+
+ assertEquals(1, out.size());
+ assertEquals("BLOCKED:reported stolen", out.get(0).value);
+ }
+
+ @Test
+ void describeTheTopology() throws Exception {
+ var t = new Transcript("05-topology-description.txt");
+ t.line(topology.describe().toString().trim());
+ t.write();
+ assertTrue(topology.describe().toString().contains(FraudTopology.COUNT_STORE));
+ }
+}
diff --git a/kafka-streams/src/test/java/com/ankurm/streams/Transcript.java b/kafka-streams/src/test/java/com/ankurm/streams/Transcript.java
new file mode 100644
index 0000000..facfac0
--- /dev/null
+++ b/kafka-streams/src/test/java/com/ankurm/streams/Transcript.java
@@ -0,0 +1,23 @@
+package com.ankurm.streams;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Collects lines and writes them to output/NN-name.txt, so the test IS the transcript. */
+final class Transcript {
+ private final List lines = new ArrayList<>();
+ private final String file;
+
+ Transcript(String file) { this.file = file; }
+
+ void line(String s) { lines.add(s); }
+
+ void write() throws IOException {
+ Path dir = Path.of("output");
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(file), lines);
+ }
+}