From d1edc0b2f7b9730b56eb39da65e5c468dc8c2c00 Mon Sep 17 00:00:00 2001
From: Dara C
Date: Wed, 26 Nov 2025 14:38:15 -0500
Subject: [PATCH 01/11] Added StatsActor
---
app/actors/StatsActor.java | 59 ++++++++
app/actors/UserActor.java | 296 ++++++++++++++++++++++---------------
2 files changed, 235 insertions(+), 120 deletions(-)
create mode 100644 app/actors/StatsActor.java
diff --git a/app/actors/StatsActor.java b/app/actors/StatsActor.java
new file mode 100644
index 0000000..21dadac
--- /dev/null
+++ b/app/actors/StatsActor.java
@@ -0,0 +1,59 @@
+package actors;
+
+import models.Article;
+import models.Stats;
+import org.apache.pekko.actor.typed.ActorRef;
+import org.apache.pekko.actor.typed.Behavior;
+import org.apache.pekko.actor.typed.javadsl.Behaviors;
+import org.apache.pekko.stream.javadsl.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Actor responsible for calculating statistics on articles.
+ * It receives a list of articles and computes word frequency statistics,
+ * then sends the results back to the requester.
+ * @author Dara Cunningham
+ */
+public class StatsActor {
+ // Message
+ public interface Message {}
+
+ public static final class CalculateStats implements Message {
+ final String query;
+ final List articles;
+ final ActorRef replyTo;
+
+ public CalculateStats(String query, List articles, ActorRef replyTo) {
+ this.query = query;
+ this.articles = articles;
+ this.replyTo = replyTo;
+ }
+ }
+
+ public static final class StatsResult implements Message {
+ final String query;
+ final List> wordCounts;
+
+ public StatsResult(String query, List> wordCounts) {
+ this.query = query;
+ this.wordCounts = wordCounts;
+ }
+ }
+
+ public static Behavior create() {
+ return Behaviors.receive(Message.class)
+ .onMessage(CalculateStats.class, msg -> {
+ List> counts = Stats.calculateSortedCounts(msg.articles);
+
+ // Send back result
+ msg.replyTo.tell(new StatsResult(msg.query, counts));
+
+ return Behaviors.same();
+ })
+ .build();
+ }
+
+}
+
diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java
index a7c2162..d5358e4 100644
--- a/app/actors/UserActor.java
+++ b/app/actors/UserActor.java
@@ -18,128 +18,184 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
import java.util.concurrent.CompletionStage;
+/**
+ * Actor that manages a user's WebSocket connection and search history.
+ * It handles incoming search requests, fetches results from the News API,
+ * maintains a history of searches, and communicates with a StatsActor
+ * to compute statistics on search results.
+ * @author Nattamon Paiboon
+ * @author Dara Cunningham
+ */
public class UserActor {
- // --- Messages ---
- public interface Message {}
-
- public static final class Connect implements Message {
- final ActorRef> replyTo;
- public Connect(ActorRef> replyTo) {
- this.replyTo = replyTo;
- }
- }
-
- public static final class PerformSearch implements Message {
- public final String query;
- public final String sortBy;
- public PerformSearch(String query, String sortBy) {
- this.query = query;
- this.sortBy = sortBy;
- }
- }
-
- // Internal message to stop the actor safely
- private static final class InternalStop implements Message {}
-
- // --- State ---
- private final String id;
- private final WSClient ws;
- private final String apiKey;
- private final ActorContext context;
- private final List searchHistory = new ArrayList<>();
-
- private final Sink hubSink;
- private final Flow websocketFlow;
-
- // TODO rm cache
- private final AsyncCacheApi asyncCache;
- // --- Factory ---
- // TODO rm cache
- public static Behavior create(String id, WSClient ws, String apiKey, AsyncCacheApi asyncCache) {
- return Behaviors.setup(context -> new UserActor(id, ws, apiKey, asyncCache, context).behavior());
- }
-
- // --- Constructor ---
- // TODO rm cache
- private UserActor(String id, WSClient ws, String apiKey, AsyncCacheApi asyncCache, ActorContext context) {
- this.id = id;
- this.ws = ws;
- this.apiKey = apiKey;
- this.context = context;
- this.asyncCache = asyncCache;
-
- Materializer mat = Materializer.matFromSystem(context.getSystem());
-
- ActorRef self = context.getSelf();
-
- Pair, Source> sinkSourcePair =
- MergeHub.of(JsonNode.class, 16)
- .toMat(BroadcastHub.of(JsonNode.class, 256), Keep.both())
- .run(mat);
-
- this.hubSink = sinkSourcePair.first();
- Source hubSource = sinkSourcePair.second();
-
- // Handle incoming messages from the WebSocket
- Sink> jsonSink = Sink.foreach((JsonNode json) -> {
- if (json.has("query")) {
- String query = json.get("query").asText();
- String sortBy = "popularity";
- if (json.has("sortBy")) {
- sortBy = json.get("sortBy").asText();
- }
- self.tell(new PerformSearch(query, sortBy));
- }
- });
-
- // ping websocket to keep it alive
- Source heartbeat = Source.tick(
- Duration.ZERO,
- Duration.ofSeconds(30),
- (JsonNode) Json.newObject().put("type", "ping")
- ).mapMaterializedValue(c -> NotUsed.getInstance());
-
- this.websocketFlow = Flow.fromSinkAndSourceCoupled(jsonSink, hubSource.merge(heartbeat))
- .watchTermination((n, stage) -> {
- stage.whenComplete((done, throwable) -> self.tell(new InternalStop()));
- return NotUsed.getInstance();
- });
- }
-
- // --- Behavior ---
- private Behavior behavior() {
- return Behaviors.receive(Message.class)
- .onMessage(Connect.class, msg -> {
- context.getLog().info("Client connected: {}", id);
- msg.replyTo.tell(websocketFlow);
- return Behaviors.same();
- })
- .onMessage(PerformSearch.class, msg -> {
- context.getLog().info("User {} searching for: {}", id, msg.query);
-
- Search search = new Search(msg.query, msg.sortBy, apiKey);
-
- search.fetchResults(ws).thenAccept(v -> {
- searchHistory.add(0, search);
- if (searchHistory.size() > 10) searchHistory.remove(searchHistory.size() - 1);
-
- // TODO rm cache
- asyncCache.set(id, new ArrayList<>(searchHistory));
-
- // Send updated results back to the user
- JsonNode response = Json.toJson(searchHistory);
- Source.single(response).runWith(hubSink, Materializer.matFromSystem(context.getSystem()));
- });
- return Behaviors.same();
- })
- // Handle the stop message
- .onMessage(InternalStop.class, msg -> {
- context.getLog().info("WebSocket closed for user {}. Stopping actor.", id);
- return Behaviors.stopped();
- })
- .build();
- }
+ // --- Messages ---
+ public interface Message {
+ }
+
+ public static final class Connect implements Message {
+ final ActorRef> replyTo;
+
+ public Connect(ActorRef> replyTo) {
+ this.replyTo = replyTo;
+ }
+ }
+
+ public static final class PerformSearch implements Message {
+ public final String query;
+ public final String sortBy;
+
+ public PerformSearch(String query, String sortBy) {
+ this.query = query;
+ this.sortBy = sortBy;
+ }
+ }
+
+ // Internal message to stop the actor safely
+ private static final class InternalStop implements Message {
+ }
+
+ // --- State ---
+ private final String id;
+ private final WSClient ws;
+ private final String apiKey;
+ private final ActorContext context;
+ private final List searchHistory = new ArrayList<>();
+
+ private final Sink hubSink;
+ private final Flow websocketFlow;
+
+ private final ActorRef statsActor;
+ private final Materializer mat;
+
+
+ // TODO rm cache
+ private final AsyncCacheApi asyncCache;
+
+ // --- Factory ---
+ // TODO rm cache
+ public static Behavior create(String id, WSClient ws, String apiKey, AsyncCacheApi asyncCache) {
+ return Behaviors.setup(context -> new UserActor(id, ws, apiKey, asyncCache, context).behavior());
+ }
+
+ // --- Constructor ---
+ // TODO rm cache
+ private UserActor(String id, WSClient ws, String apiKey, AsyncCacheApi asyncCache, ActorContext context) {
+ this.id = id;
+ this.ws = ws;
+ this.apiKey = apiKey;
+ this.context = context;
+ this.asyncCache = asyncCache;
+
+ // Spawn StatsActor
+ this.statsActor = context.spawn(StatsActor.create(), "statsActor-" + id);
+ this.mat = Materializer.matFromSystem(context.getSystem());
+
+ Materializer mat = Materializer.matFromSystem(context.getSystem());
+
+ ActorRef self = context.getSelf();
+
+ Pair, Source> sinkSourcePair =
+ MergeHub.of(JsonNode.class, 16)
+ .toMat(BroadcastHub.of(JsonNode.class, 256), Keep.both())
+ .run(mat);
+
+ this.hubSink = sinkSourcePair.first();
+ Source hubSource = sinkSourcePair.second();
+
+ // Handle incoming messages from the WebSocket
+ Sink> jsonSink = Sink.foreach((JsonNode json) -> {
+ if (json.has("query")) {
+ String query = json.get("query").asText();
+ String sortBy = "popularity";
+ if (json.has("sortBy")) {
+ sortBy = json.get("sortBy").asText();
+ }
+ self.tell(new PerformSearch(query, sortBy));
+ }
+ });
+
+ // ping websocket to keep it alive
+ Source heartbeat = Source.tick(
+ Duration.ZERO,
+ Duration.ofSeconds(30),
+ (JsonNode) Json.newObject().put("type", "ping")
+ ).mapMaterializedValue(c -> NotUsed.getInstance());
+
+ this.websocketFlow = Flow.fromSinkAndSourceCoupled(jsonSink, hubSource.merge(heartbeat))
+ .watchTermination((n, stage) -> {
+ stage.whenComplete((done, throwable) -> self.tell(new InternalStop()));
+ return NotUsed.getInstance();
+ });
+ }
+
+ // --- Behavior ---
+ private Behavior behavior() {
+ return Behaviors.receive(Message.class)
+ .onMessage(Connect.class, msg -> {
+ context.getLog().info("Client connected: {}", id);
+ msg.replyTo.tell(websocketFlow);
+ return Behaviors.same();
+ })
+ .onMessage(PerformSearch.class, msg -> {
+ context.getLog().info("User {} searching for: {}", id, msg.query);
+
+ Search search = new Search(msg.query, msg.sortBy, apiKey);
+
+ search.fetchResults(ws).thenAccept(v -> {
+ searchHistory.add(0, search);
+ if (searchHistory.size() > 10) searchHistory.remove(searchHistory.size() - 1);
+
+ // TODO rm cache
+ asyncCache.set(id, new ArrayList<>(searchHistory));
+
+ // Send updated results back to the user
+ JsonNode response = Json.toJson(searchHistory);
+ Source.single(response).runWith(hubSink, Materializer.matFromSystem(context.getSystem()));
+ });
+ return Behaviors.same();
+ })
+ // Handle the stop message
+ .onMessage(InternalStop.class, msg -> {
+ context.getLog().info("WebSocket closed for user {}. Stopping actor.", id);
+ return Behaviors.stopped();
+ })
+ .onMessage(GetStats.class, msg -> {
+ Optional searchOpt = searchHistory.stream()
+ .filter(s -> s.getRawQuery().equals(msg.query))
+ .findFirst();
+
+ // Ask StatsActor to calculate (non-blocking)
+ searchOpt.ifPresent(search -> context.ask(
+ StatsActor.StatsResult.class,
+ statsActor,
+ Duration.ofSeconds(5),
+ replyTo -> new StatsActor.CalculateStats(
+ msg.query,
+ search.getResults(),
+ replyTo
+ ),
+ (response, throwable) -> {
+ if (response != null) {
+ // Convert to JSON and send via WebSocket
+ JsonNode json = Json.toJson(response);
+ Source.single(json).runWith(hubSink, mat);
+ }
+ return new PerformSearch("", ""); // Dummy message
+ }
+ ));
+ return Behaviors.same();
+ })
+ .build();
+ }
+
+ public static final class GetStats implements Message {
+ final String query;
+
+ public GetStats(String query) {
+ this.query = query;
+ }
+ }
}
\ No newline at end of file
From ddefa209b0294d42240263773067308515b35230 Mon Sep 17 00:00:00 2001
From: Dara C
Date: Sat, 29 Nov 2025 16:56:08 -0500
Subject: [PATCH 02/11] HomeController: modified stats() to use sessionID
instead of user token StatsActor: extended functionality SupervisorActor:
added UserStatsResponse and GetStats, and onMessage behavior UserActor: added
onGetStats
---
app/actors/StatsActor.java | 136 +++++++++++++++++++++++----
app/actors/SupervisorActor.java | 76 ++++++++++++++-
app/actors/UserActor.java | 85 ++++++++++++++++-
app/assets/javascripts/search.coffee | 13 ++-
app/controllers/HomeController.java | 65 +++++++------
app/views/results.scala.html | 4 +-
app/views/stats.scala.html | 2 +-
7 files changed, 329 insertions(+), 52 deletions(-)
diff --git a/app/actors/StatsActor.java b/app/actors/StatsActor.java
index 21dadac..c35fde7 100644
--- a/app/actors/StatsActor.java
+++ b/app/actors/StatsActor.java
@@ -5,7 +5,7 @@
import org.apache.pekko.actor.typed.ActorRef;
import org.apache.pekko.actor.typed.Behavior;
import org.apache.pekko.actor.typed.javadsl.Behaviors;
-import org.apache.pekko.stream.javadsl.*;
+import org.apache.pekko.actor.typed.javadsl.ActorContext;
import java.util.List;
import java.util.Map;
@@ -17,21 +17,116 @@
* @author Dara Cunningham
*/
public class StatsActor {
- // Message
public interface Message {}
- public static final class CalculateStats implements Message {
+ /**
+ * Request to compute word statistics for a list of articles
+ */
+ public static final class ComputeStats implements Message {
final String query;
final List articles;
- final ActorRef replyTo;
+ final ActorRef replyTo;
- public CalculateStats(String query, List articles, ActorRef replyTo) {
+ public ComputeStats(String query, List articles, ActorRef replyTo) {
this.query = query;
this.articles = articles;
this.replyTo = replyTo;
}
}
+ /**
+ * Internal message when computation completes successfully
+ */
+ private static final class ComputeComplete implements Message {
+ final String query;
+ final List> sortedCounts;
+ final ActorRef replyTo;
+
+ ComputeComplete(String query, List> sortedCounts, ActorRef replyTo) {
+ this.query = query;
+ this.sortedCounts = sortedCounts;
+ this.replyTo = replyTo;
+ }
+ }
+
+ /**
+ * Internal message when computation fails
+ */
+ private static final class ComputeFailed implements Message {
+ final String query;
+ final String error;
+ final ActorRef replyTo;
+
+ ComputeFailed(String query, String error, ActorRef replyTo) {
+ this. query = query;
+ this.error = error;
+ this.replyTo = replyTo;
+ }
+ }
+
+ // --- State ---
+ private final ActorContext context;
+
+ // --- Factory ---
+ public static Behavior create() {
+ return Behaviors.setup(context -> new StatsActor(context). behavior());
+ }
+
+ // --- Constructor ---
+ private StatsActor(ActorContext context) {
+ this.context = context;
+ }
+
+ // --- Behavior ---
+ private Behavior behavior() {
+ return Behaviors.receive(Message.class)
+ .onMessage(ComputeStats.class, this::onComputeStats)
+ .onMessage(ComputeComplete. class, this::onComputeComplete)
+ .onMessage(ComputeFailed.class, this::onComputeFailed)
+ .build();
+ }
+
+ private Behavior onComputeStats(ComputeStats msg) {
+ context.getLog().info("Computing stats for query: {} ({} articles)",
+ msg.query, msg.articles. size());
+
+ ActorRef self = context. getSelf();
+
+ try {
+ // Compute word statistics using existing Stats utility
+ List> sortedCounts = Stats.calculateSortedCounts(msg.articles);
+
+ // Send completion message to self
+ self.tell(new ComputeComplete(msg.query, sortedCounts, msg.replyTo));
+
+ } catch (Exception e) {
+ context.getLog().error("Failed to compute stats for query: {}", msg.query, e);
+ self. tell(new ComputeFailed(msg. query, e.getMessage(), msg.replyTo));
+ }
+
+ return Behaviors.same();
+ }
+
+ private Behavior onComputeComplete(ComputeComplete msg) {
+ context.getLog(). info("Stats computed successfully for query: {} - {} unique words",
+ msg. query, msg.sortedCounts.size());
+
+ // Reply to the requesting UserActor with the results
+ msg.replyTo. tell(new UserActor.StatsResult(true, msg.query, msg.sortedCounts));
+
+ return Behaviors.same();
+ }
+
+ private Behavior onComputeFailed(ComputeFailed msg) {
+ context.getLog().error("Stats computation failed for query: {}, error: {}",
+ msg. query, msg.error);
+
+ // Reply to the requesting UserActor with failure
+ msg.replyTo.tell(new UserActor.StatsResult(false, msg.query, List.of()));
+
+ return Behaviors.same();
+ }
+
public static final class StatsResult implements Message {
final String query;
final List> wordCounts;
@@ -42,18 +137,27 @@ public StatsResult(String query, List> wordCounts) {
}
}
- public static Behavior create() {
- return Behaviors.receive(Message.class)
- .onMessage(CalculateStats.class, msg -> {
- List> counts = Stats.calculateSortedCounts(msg.articles);
+ // Factory
+// public static Behavior create() {
+// return Behaviors.setup(context -> new StatsActor(context).behavior());
+// }
- // Send back result
- msg.replyTo.tell(new StatsResult(msg.query, counts));
-
- return Behaviors.same();
- })
- .build();
- }
+ /**
+ * Handler for CalculateStats messages.
+ * Computes the word frequency and sends result back to the requester.
+ */
+// private Behavior onComputeStats_(ComputeStats msg) {
+// context.getLog().info("Computing stats for query: {}", msg. query);
+//
+// // Use your existing Stats utility to calculate word counts
+// List> sortedCounts =
+// Stats.calculateSortedCounts(msg.articles);
+//
+// // Send the result back to whoever asked
+// msg.replyTo.tell(new StatsResult(msg.query, sortedCounts));
+//
+// return Behaviors.same(); // Keep the same behavior
+// }
}
diff --git a/app/actors/SupervisorActor.java b/app/actors/SupervisorActor.java
index d29e2e8..1b10291 100644
--- a/app/actors/SupervisorActor.java
+++ b/app/actors/SupervisorActor.java
@@ -11,6 +11,7 @@
import java.util.HashMap;
import java.util.Map;
+import java.util.List;
/**
* Parent actor that manages the creation of individual UserActors
@@ -42,6 +43,32 @@ public Create(String id, ActorRef> replyTo) {
}
}
+ /**
+ * Message to request stats for a user's query.
+ * @author Dara Cunningham
+ */
+ public static final class GetStats implements Message {
+ public final String userId;
+ public final String query;
+ public final ActorRef replyTo;
+
+ public GetStats(String userId, String query, ActorRef replyTo) {
+ this.userId = userId;
+ this.query = query;
+ this.replyTo = replyTo;
+ }
+ }
+
+ /**
+ * Response message containing stats results (sent back to HomeController).
+ * @author Dara Cunningham
+ */
+ public record StatsResponse(
+ boolean found,
+ String query,
+ List> sortedCounts
+ ) {}
+
/** Internal message indicating a UserActor has stopped.
* @author Nattamon Paiboon
*/
@@ -53,6 +80,20 @@ private static final class UserActorStopped implements Message {
}
}
+ /**
+ * Internal message to receive stats from UserActor.
+ * @author Dara Cunningham
+ */
+ private static final class UserStatsResponse implements Message {
+ final UserActor.StatsResult result;
+ final ActorRef originalReplyTo;
+
+ UserStatsResponse(UserActor.StatsResult result, ActorRef originalReplyTo) {
+ this.result = result;
+ this.originalReplyTo = originalReplyTo;
+ }
+ }
+
// TODO rm cache
/**
* Creates the SupervisorActor behavior that manages UserActors and a shared NewsActor.
@@ -61,7 +102,7 @@ private static final class UserActorStopped implements Message {
* @param apiKey API key for NewsActor
* @param asyncCache cache for storing user data
* @return Behavior for the SupervisorActor
- * @author Nattamon Paiboon, Luan Tran
+ * @author Nattamon Paiboon, Luan Tran, Dara Cunningham
* */
public static Behavior create(WSClient wsClient, String apiKey, AsyncCacheApi asyncCache) {
return Behaviors.setup(context -> {
@@ -114,6 +155,39 @@ public static Behavior create(WSClient wsClient, String apiKey, AsyncCa
return Behaviors.same();
})
+ .onMessage(GetStats.class, msg -> {
+ context.getLog().info("Received GetStats request for session: {}, query: {}",
+ msg.userId, msg.query);
+
+ ActorRef userActor = userActors.get(msg.userId);
+
+ if (userActor == null) {
+ context.getLog().info("No UserActor found for session: {}", msg.userId);
+ msg.replyTo.tell(new StatsResponse(false, msg.query, List.of()));
+ }
+ else {
+ // Create an adapter to convert UserActor's response to our internal message
+ ActorRef adapter = context.messageAdapter(
+ UserActor.StatsResult.class,
+ result -> new UserStatsResponse(result, msg.replyTo)
+ );
+
+ userActor.tell(new UserActor.GetStats(msg.query, adapter));
+ }
+
+ return Behaviors.same();
+ })
+ .onMessage(UserStatsResponse.class, msg -> {
+ context.getLog().info("Received stats response for query: {}", msg.result.query());
+
+ msg.originalReplyTo.tell(new StatsResponse(
+ msg.result.found(),
+ msg.result.query(),
+ msg.result.sortedCounts()
+ ));
+
+ return Behaviors.same();
+ })
.onMessage(UserActorStopped.class, stopped -> {
context.getLog().info("UserActor terminated for session: {}, removing from registry", stopped.id);
userActors.remove(stopped.id);
diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java
index b1c28dd..f42b1d6 100644
--- a/app/actors/UserActor.java
+++ b/app/actors/UserActor.java
@@ -3,9 +3,9 @@
import org.apache.pekko.Done;
import org.apache.pekko.NotUsed;
import org.apache.pekko.actor.typed.ActorRef;
-import org.apache.pekko.actor.typed.Behavior;
+import org.apache.pekko.actor.typed. Behavior;
import org.apache.pekko.actor.typed.javadsl.ActorContext;
-import org.apache.pekko.actor.typed.javadsl.Behaviors;
+import org.apache.pekko.actor.typed.javadsl. Behaviors;
import org.apache.pekko.stream.Materializer;
import org.apache.pekko.stream.javadsl.*;
import org.apache.pekko.japi.Pair;
@@ -13,6 +13,9 @@
import play.cache.AsyncCacheApi;
import play.libs.Json;
import models.Search;
+import models.Article;
+import models.Search;
+import models.Stats;
import java.time.Duration;
import java.util.*;
@@ -85,6 +88,28 @@ public ReadabilityResult(List searches) {
}
}
+ /**
+ * Request to compute stats for a query (from SupervisorActor)
+ * @author Dara Cunningham
+ */
+ public static final class GetStats implements Message {
+ public final String query;
+ public final ActorRef replyTo;
+ public GetStats(String query, ActorRef replyTo) {
+ this.query = query;
+ this.replyTo = replyTo;
+ }
+ }
+ /**
+ * Response containing stats computation result.
+ * Implements Message so StatsActor can send it directly to UserActor.
+ * @author Dara Cunningham
+ */
+ public record StatsResult(
+ boolean found,
+ String query,
+ List> sortedCounts
+ ) implements Message {}
/**
* Internal message to handle WebSocket disconnection
@@ -98,11 +123,12 @@ private static final class InternalStop implements Message {}
// private final String apiKey;
private final ActorRef newsActor;
private final ActorRef rActor;
+ private final ActorRef statsActor;
private final ActorContext context;
private List searchHistory = new ArrayList<>();
private final Set watchedQueries = new HashSet<>();
private final Map searchInstances = new HashMap<>();
-
+ private final Map> pendingStatsRequests = new HashMap<>();
private final Sink hubSink;
private final Flow websocketFlow;
@@ -160,9 +186,9 @@ private UserActor(String id, AsyncCacheApi asyncCache, ActorContext con
this.rActor = rActor;
Materializer mat = Materializer.matFromSystem(context.getSystem());
+
// Spawn StatsActor
this.statsActor = context.spawn(StatsActor.create(), "statsActor-" + id);
- this.mat = Materializer.matFromSystem(context.getSystem());
ActorRef self = context.getSelf();
@@ -224,6 +250,8 @@ private Behavior behavior() {
.onMessage(NewsResults.class, this::onNewsResults)
.onMessage(NewsFetchFailed.class, this::onNewsFetchFailed)
.onMessage(ReadabilityResult.class, this::onReadabilityResult)
+ .onMessage(GetStats.class, this::onGetStats)
+ .onMessage(StatsResult.class, this::onStatsResult)
.onMessage(InternalStop.class, this::onInternalStop)
.build();
}
@@ -312,6 +340,55 @@ private Behavior onNewsFetchFailed(UserActor.NewsFetchFailed
return Behaviors.same();
}
+ /**
+ * Handles GetStats request by delegating to StatsActor
+ * @author Dara Cunningham
+ */
+ private Behavior onGetStats(GetStats msg) {
+ context.getLog().info("User {} requesting stats for: {}", id, msg.query);
+
+ Optional searchOpt = searchHistory.stream()
+ .filter(s -> s.getRawQuery().equals(msg.query))
+ .findFirst();
+
+ if (searchOpt.isEmpty()) {
+ context.getLog().info("Search not found for query: {}", msg.query);
+ msg.replyTo.tell(new StatsResult(false, msg.query, List.of()));
+ }
+ else {
+ Search search = searchOpt.get();
+ List articles = search.getResults();
+
+ context.getLog().info("Delegating stats computation for {} articles to StatsActor", articles.size());
+
+ // Store the pending request so we can forward the response
+ pendingStatsRequests.put(msg.query, msg.replyTo);
+
+ // Delegate to StatsActor - it will reply directly with StatsResult
+ statsActor.tell(new StatsActor.ComputeStats(msg.query, articles, context.getSelf()));
+ }
+
+ return Behaviors.same();
+ }
+
+ /**
+ * Handles StatsResult from StatsActor and forwards to original requester
+ * @author Dara Cunningham
+ */
+ private Behavior onStatsResult(StatsResult msg) {
+ context. getLog().info("Received stats result for query: {} (found: {})", msg.query(), msg. found());
+
+ ActorRef originalReplyTo = pendingStatsRequests.remove(msg.query());
+
+ if (originalReplyTo != null) {
+ originalReplyTo.tell(msg);
+ } else {
+ context.getLog(). warn("No pending stats request found for query: {}", msg.query());
+ }
+
+ return Behaviors. same();
+ }
+
/**
* Handles WebSocket disconnection while preserving actor state for reconnection
* @param msg the internal stop message
diff --git a/app/assets/javascripts/search.coffee b/app/assets/javascripts/search.coffee
index c9d15d9..bb47470 100644
--- a/app/assets/javascripts/search.coffee
+++ b/app/assets/javascripts/search.coffee
@@ -4,7 +4,18 @@ formatNumber = (val) ->
num = Number(val)
if isNaN(num) then "N/A" else num.toFixed(2)
+# Get session ID from localStorage
+getSessionId = ->
+ sessionId = localStorage.getItem('newsapp_session_id')
+ if ! sessionId
+ console.warn "No session ID found in localStorage"
+ return ""
+ return sessionId
+
window.App.renderSearch = (search) ->
+ # Get session ID for stats link
+ sessionId = getSessionId()
+
# Sentiment part
sentimentHtml = ""
if search.sentiment == ":-)"
@@ -42,7 +53,7 @@ window.App.renderSearch = (search) ->
-
+
Statistics for "#{search.rawQuery}"
diff --git a/app/controllers/HomeController.java b/app/controllers/HomeController.java
index 67d7604..0dd2b7d 100644
--- a/app/controllers/HomeController.java
+++ b/app/controllers/HomeController.java
@@ -189,32 +189,43 @@ private CompletionStage fetchSourcePage(String identifier, String source
* @param rawQuery the search query string
* @return stats page specific to the query
*/
- public CompletionStage stats(Http.Request request, String rawQuery) {
- // Use session token or generate one
- String token = request.session().get("user_token").orElseGet(() -> {
- return UUID.randomUUID().toString();
- });
-
- // Get search history from cache
- return asyncCache.get(token).thenApply(optionalHistory -> {
- List history = (ArrayList) optionalHistory.orElseGet(ArrayList::new);
-
- // Find the specific search in history
- Optional searchOpt = history.stream()
- .filter(s -> s.getRawQuery().equals(rawQuery))
- .findFirst();
-
- if (searchOpt.isEmpty()) {
- return notFound("Search not found")
- .addingToSession(request, "user_token", token);
- }
-
- Search search = searchOpt.get();
- StatsInfo statsInfo = new StatsInfo(rawQuery, search.getResults());
-
- return ok(views.html.stats.render(rawQuery, (java.util.List) statsInfo.getSortedCounts()))
- .addingToSession(request, "user_token", token);
- });
- }
+ public CompletionStage stats(Http.Request request, String rawQuery) {
+ // Try to get session ID from query parameter first (for stats links),
+ // then fall back to session cookie
+ String token = request.queryString("sessionId")
+ .orElseGet(() -> request.session().get("user_token").orElseGet(() -> {
+ String newId = UUID.randomUUID().toString();
+ System.out.println("WARNING: No sessionId from query or session, generated: " + newId);
+ return newId;
+ }));
+
+ System.out.println(">>> Stats request - Session ID: " + token + ", Query: " + rawQuery);
+
+ // Ask SupervisorActor for stats
+ CompletionStage statsFuture = AskPattern.ask(
+ userParentActor,
+ (ActorRef replyTo) ->
+ new SupervisorActor.GetStats(token, rawQuery, replyTo),
+ Duration.ofSeconds(10),
+ actorSystem.scheduler()
+ );
+
+ return statsFuture.thenApply(response -> {
+ if (response.found()) {
+ @SuppressWarnings("unchecked")
+ List> wildcardList = (List>) (List >) response.sortedCounts();
+
+ return ok(views.html.stats.render(rawQuery, wildcardList))
+ .addingToSession(request, "user_token", token);
+ }
+ else {
+ return notFound("Search not found")
+ .addingToSession(request, "user_token", token);
+ }
+ }).exceptionally(e -> {
+ e.printStackTrace();
+ return internalServerError("Error computing stats");
+ });
+ }
}
diff --git a/app/views/results.scala.html b/app/views/results.scala.html
index 2aa66d3..4b5053c 100644
--- a/app/views/results.scala.html
+++ b/app/views/results.scala.html
@@ -1,5 +1,5 @@
@import scala.jdk.CollectionConverters._
-@(history: java.util.List[Search])
+@(history: java.util.List[Search], sessionToken: String)
@main("NotiLytics Search Results") {
@search()
@@ -33,7 +33,7 @@ Search Results
-
+
Statistics for "@searchForm.getRawQuery"
diff --git a/app/views/stats.scala.html b/app/views/stats.scala.html
index 04a83a9..74a027f 100644
--- a/app/views/stats.scala.html
+++ b/app/views/stats.scala.html
@@ -1,7 +1,7 @@
@import scala.jdk.CollectionConverters._
@import java.util.Map.Entry
-@(rawQuery: String, sortedCounts: java.util.List[Entry[String, Int]])
+@(rawQuery: String, sortedCounts: java.util.List[Entry[_, _]])
@main("NotiLytics - Statistics for '" + rawQuery + "'") {
Word Statistics
From 7a0d7442c12e3ba304ea536d787e14ac6662a305 Mon Sep 17 00:00:00 2001
From: Dara C
Date: Sat, 29 Nov 2025 16:57:36 -0500
Subject: [PATCH 03/11] HomeController: modified stats() to use sessionID
instead of user token StatsActor: extended functionality SupervisorActor:
added UserStatsResponse and GetStats, and onMessage behavior UserActor: added
onGetStats
---
app/actors/StatsActor.java | 183 +++++++++++++++----------------------
1 file changed, 75 insertions(+), 108 deletions(-)
diff --git a/app/actors/StatsActor.java b/app/actors/StatsActor.java
index c35fde7..c7da441 100644
--- a/app/actors/StatsActor.java
+++ b/app/actors/StatsActor.java
@@ -17,11 +17,12 @@
* @author Dara Cunningham
*/
public class StatsActor {
- public interface Message {}
+ public interface Message {
+ }
/**
- * Request to compute word statistics for a list of articles
- */
+ * Request to compute word statistics for a list of articles
+ */
public static final class ComputeStats implements Message {
final String query;
final List articles;
@@ -35,129 +36,95 @@ public ComputeStats(String query, List articles, ActorRef> sortedCounts;
- final ActorRef replyTo;
-
- ComputeComplete(String query, List> sortedCounts, ActorRef replyTo) {
- this.query = query;
- this.sortedCounts = sortedCounts;
- this.replyTo = replyTo;
- }
- }
+ * Internal message when computation completes successfully
+ */
+ private static final class ComputeComplete implements Message {
+ final String query;
+ final List> sortedCounts;
+ final ActorRef replyTo;
- /**
- * Internal message when computation fails
- */
- private static final class ComputeFailed implements Message {
- final String query;
- final String error;
- final ActorRef replyTo;
-
- ComputeFailed(String query, String error, ActorRef replyTo) {
- this. query = query;
- this.error = error;
- this.replyTo = replyTo;
- }
- }
+ ComputeComplete(String query, List> sortedCounts, ActorRef replyTo) {
+ this.query = query;
+ this.sortedCounts = sortedCounts;
+ this.replyTo = replyTo;
+ }
+ }
- // --- State ---
- private final ActorContext context;
-
- // --- Factory ---
- public static Behavior create() {
- return Behaviors.setup(context -> new StatsActor(context). behavior());
- }
-
- // --- Constructor ---
- private StatsActor(ActorContext context) {
- this.context = context;
- }
-
- // --- Behavior ---
- private Behavior behavior() {
- return Behaviors.receive(Message.class)
- .onMessage(ComputeStats.class, this::onComputeStats)
- .onMessage(ComputeComplete. class, this::onComputeComplete)
- .onMessage(ComputeFailed.class, this::onComputeFailed)
- .build();
- }
+ /**
+ * Internal message when computation fails
+ */
+ private static final class ComputeFailed implements Message {
+ final String query;
+ final String error;
+ final ActorRef replyTo;
- private Behavior onComputeStats(ComputeStats msg) {
- context.getLog().info("Computing stats for query: {} ({} articles)",
- msg.query, msg.articles. size());
+ ComputeFailed(String query, String error, ActorRef replyTo) {
+ this.query = query;
+ this.error = error;
+ this.replyTo = replyTo;
+ }
+ }
- ActorRef self = context. getSelf();
+ // --- State ---
+ private final ActorContext context;
- try {
- // Compute word statistics using existing Stats utility
- List> sortedCounts = Stats.calculateSortedCounts(msg.articles);
+ // --- Factory ---
+ public static Behavior create() {
+ return Behaviors.setup(context -> new StatsActor(context).behavior());
+ }
- // Send completion message to self
- self.tell(new ComputeComplete(msg.query, sortedCounts, msg.replyTo));
+ // --- Constructor ---
+ private StatsActor(ActorContext context) {
+ this.context = context;
+ }
- } catch (Exception e) {
- context.getLog().error("Failed to compute stats for query: {}", msg.query, e);
- self. tell(new ComputeFailed(msg. query, e.getMessage(), msg.replyTo));
- }
+ // --- Behavior ---
+ private Behavior behavior() {
+ return Behaviors.receive(Message.class)
+ .onMessage(ComputeStats.class, this::onComputeStats)
+ .onMessage(ComputeComplete.class, this::onComputeComplete)
+ .onMessage(ComputeFailed.class, this::onComputeFailed)
+ .build();
+ }
- return Behaviors.same();
- }
+ private Behavior onComputeStats(ComputeStats msg) {
+ context.getLog().info("Computing stats for query: {} ({} articles)",
+ msg.query, msg.articles.size());
- private Behavior onComputeComplete(ComputeComplete msg) {
- context.getLog(). info("Stats computed successfully for query: {} - {} unique words",
- msg. query, msg.sortedCounts.size());
+ ActorRef self = context.getSelf();
- // Reply to the requesting UserActor with the results
- msg.replyTo. tell(new UserActor.StatsResult(true, msg.query, msg.sortedCounts));
+ try {
+ // Compute word statistics using existing Stats utility
+ List> sortedCounts = Stats.calculateSortedCounts(msg.articles);
- return Behaviors.same();
- }
+ // Send completion message to self
+ self.tell(new ComputeComplete(msg.query, sortedCounts, msg.replyTo));
- private Behavior onComputeFailed(ComputeFailed msg) {
- context.getLog().error("Stats computation failed for query: {}, error: {}",
- msg. query, msg.error);
+ } catch (Exception e) {
+ context.getLog().error("Failed to compute stats for query: {}", msg.query, e);
+ self.tell(new ComputeFailed(msg.query, e.getMessage(), msg.replyTo));
+ }
- // Reply to the requesting UserActor with failure
- msg.replyTo.tell(new UserActor.StatsResult(false, msg.query, List.of()));
+ return Behaviors.same();
+ }
- return Behaviors.same();
- }
+ private Behavior onComputeComplete(ComputeComplete msg) {
+ context.getLog().info("Stats computed successfully for query: {} - {} unique words",
+ msg.query, msg.sortedCounts.size());
- public static final class StatsResult implements Message {
- final String query;
- final List> wordCounts;
+ // Reply to the requesting UserActor with the results
+ msg.replyTo.tell(new UserActor.StatsResult(true, msg.query, msg.sortedCounts));
- public StatsResult(String query, List> wordCounts) {
- this.query = query;
- this.wordCounts = wordCounts;
- }
+ return Behaviors.same();
}
- // Factory
-// public static Behavior create() {
-// return Behaviors.setup(context -> new StatsActor(context).behavior());
-// }
+ private Behavior onComputeFailed(ComputeFailed msg) {
+ context.getLog().error("Stats computation failed for query: {}, error: {}",
+ msg.query, msg.error);
+ // Reply to the requesting UserActor with failure
+ msg.replyTo.tell(new UserActor.StatsResult(false, msg.query, List.of()));
- /**
- * Handler for CalculateStats messages.
- * Computes the word frequency and sends result back to the requester.
- */
-// private Behavior onComputeStats_(ComputeStats msg) {
-// context.getLog().info("Computing stats for query: {}", msg. query);
-//
-// // Use your existing Stats utility to calculate word counts
-// List> sortedCounts =
-// Stats.calculateSortedCounts(msg.articles);
-//
-// // Send the result back to whoever asked
-// msg.replyTo.tell(new StatsResult(msg.query, sortedCounts));
-//
-// return Behaviors.same(); // Keep the same behavior
-// }
+ return Behaviors.same();
+ }
}
-
From 1748feecfbd7961fa40e8efba1313e7439bdb047 Mon Sep 17 00:00:00 2001
From: Dara C
Date: Sat, 29 Nov 2025 18:21:33 -0500
Subject: [PATCH 04/11] Merged from main
---
app/actors/StatsActor.java | 12 ++-
app/actors/SupervisorActor.java | 145 +++++++++++++++++-----------
app/actors/UserActor.java | 32 ++++++
app/controllers/HomeController.java | 2 +-
4 files changed, 132 insertions(+), 59 deletions(-)
diff --git a/app/actors/StatsActor.java b/app/actors/StatsActor.java
index c7da441..c374260 100644
--- a/app/actors/StatsActor.java
+++ b/app/actors/StatsActor.java
@@ -17,8 +17,7 @@
* @author Dara Cunningham
*/
public class StatsActor {
- public interface Message {
- }
+ public interface Message {}
/**
* Request to compute word statistics for a list of articles
@@ -35,6 +34,15 @@ public ComputeStats(String query, List articles, ActorRef> sortedCounts
+ ) {}
+
/**
* Internal message when computation completes successfully
*/
diff --git a/app/actors/SupervisorActor.java b/app/actors/SupervisorActor.java
index fe35679..4b8b4c9 100644
--- a/app/actors/SupervisorActor.java
+++ b/app/actors/SupervisorActor.java
@@ -9,8 +9,7 @@
import play.cache.AsyncCacheApi;
import play.libs.ws.WSClient;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.*;
/**
* Parent actor that manages the creation of individual UserActors for each WebSocket connection.
@@ -55,6 +54,30 @@ public GetStats(String userId, String query, ActorRef replyTo) {
}
}
+ /**
+ * Internal message to receive stats from UserActor.
+ * @author Dara Cunningham
+ */
+ private static final class UserStatsResponse implements Message {
+ final UserActor.StatsResult result;
+ final ActorRef originalReplyTo;
+
+ UserStatsResponse(UserActor.StatsResult result, ActorRef originalReplyTo) {
+ this.result = result;
+ this.originalReplyTo = originalReplyTo;
+ }
+ }
+
+ /**
+ * Response message containing stats results (sent back to HomeController).
+ * @author Dara Cunningham
+ */
+ public record StatsResponse(
+ boolean found,
+ String query,
+ List> sortedCounts
+ ) {}
+
/**
* For get other additional actors which will be used in controller ( sourceActor and sourceInfoActor )
*/
@@ -78,30 +101,6 @@ public OtherActors(ActorRef sourceActor, ActorRef> sortedCounts
- ) {}
-
- /**
- * Internal message to receive stats from UserActor.
- * @author Dara Cunningham
- */
- private static final class UserStatsResponse implements Message {
- final UserActor.StatsResult result;
- final ActorRef originalReplyTo;
-
- UserStatsResponse(UserActor.StatsResult result, ActorRef originalReplyTo) {
- this.result = result;
- this.originalReplyTo = originalReplyTo;
- }
- }
-
/** Internal message indicating a UserActor has stopped.
* @author Nattamon Paiboon
*/
@@ -124,34 +123,34 @@ private static final class UserActorStopped implements Message {
* @author Nattamon Paiboon, Luan Tran
* */
public static Behavior create(WSClient wsClient, String apiKey, AsyncCacheApi asyncCache) {
- return Behaviors.setup(context -> {
- // create source actor
- ActorRef sourceActor = context.spawn(
- SourceActor.create(wsClient),
- "sourceActor"
- );
- // create sourceInfo actor
- ActorRef sourcesInfoActor = context.spawn(
- SourcesInfoActor.create(wsClient, apiKey),
- "sourcesInfoActor"
- );
- context.getLog().info("sourceActor and sourcesInfoActor spawned and ready");
+ return Behaviors.setup(context -> {
+ // create source actor
+ ActorRef sourceActor = context.spawn(
+ SourceActor.create(wsClient),
+ "sourceActor"
+ );
+ // create sourceInfo actor
+ ActorRef sourcesInfoActor = context.spawn(
+ SourcesInfoActor.create(wsClient, apiKey),
+ "sourcesInfoActor"
+ );
+ context.getLog().info("sourceActor and sourcesInfoActor spawned and ready");
- // Spawn a single NewsActor that will be shared by all UserActors
- ActorRef newsActor = context.spawn(
- NewsActor.create(wsClient, apiKey), "newsActor"
- );
- context.getLog().info("NewsActor spawned and ready");
+ // Spawn a single NewsActor that will be shared by all UserActors
+ ActorRef newsActor = context.spawn(
+ NewsActor.create(wsClient, apiKey), "newsActor"
+ );
+ context.getLog().info("NewsActor spawned and ready");
- // Spawn a single ReadabilityActor that will be shared by all UserActors
- ActorRef readabilityActor = context.spawn(
- ReadabilityActor.create(), "readabilityActor"
- );
+ // Spawn a single ReadabilityActor that will be shared by all UserActors
+ ActorRef readabilityActor = context.spawn(
+ ReadabilityActor.create(), "readabilityActor"
+ );
- context.getLog().info("ReadabilityActor spawned and ready");
+ context.getLog().info("ReadabilityActor spawned and ready");
- // Registry to track active UserActors by session ID
- Map> userActors = new HashMap<>();
+ // Registry to track active UserActors by session ID
+ Map> userActors = new HashMap<>();
@@ -181,16 +180,50 @@ public static Behavior create(WSClient wsClient, String apiKey, AsyncCa
// Watch the actor to know when it stops (only happens after timeout or explicit stop)
context.watchWith(child, new UserActorStopped(create.id));
- // Ask the child to establish the stream
- child.tell(new UserActor.Connect(create.replyTo));
- }
-
+ // Ask the child to establish the stream
+ child.tell(new UserActor.Connect(create.replyTo));
+ }
return Behaviors.same();
- })
- .onMessage(GetOtherActors.class, msg -> {
+ })
+ .onMessage(GetStats.class, msg -> {
+ context.getLog().info("Received GetStats request for session: {}, query: {}",
+ msg. userId, msg.query);
+
+ ActorRef userActor = userActors.get(msg.userId);
+
+ if (userActor == null) {
+ context. getLog().info("No UserActor found for session: {}", msg.userId);
+ msg.replyTo.tell(new StatsResponse(false, msg.query, List.of()));
+ } else {
+ ActorRef adapter = context. messageAdapter(
+ UserActor. StatsResult.class,
+ result -> new UserStatsResponse(result, msg.replyTo)
+ );
+ userActor.tell(new UserActor.GetStats(msg.query, adapter));
+ }
+
+ return Behaviors.same();
+ })
+ .onMessage(UserStatsResponse.class, msg -> {
+ context.getLog().info("Received stats response for query: {}", msg. result.query());
+
+ msg.originalReplyTo.tell(new StatsResponse(
+ msg.result.found(),
+ msg.result.query(),
+ msg.result.sortedCounts()
+ ));
+
+ return Behaviors.same();
+ })
+ .onMessage(GetOtherActors.class, msg -> {
msg.replyTo.tell(new OtherActors(sourceActor, sourcesInfoActor));
return Behaviors.same();
})
+ .onMessage(UserActorStopped.class, stopped -> {
+ context.getLog(). info("UserActor terminated for session: {}, removing from registry", stopped. id);
+ userActors.remove(stopped.id);
+ return Behaviors.same();
+ })
.build();
});
}
diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java
index f42b1d6..a4e3476 100644
--- a/app/actors/UserActor.java
+++ b/app/actors/UserActor.java
@@ -111,6 +111,20 @@ public record StatsResult(
List> sortedCounts
) implements Message {}
+ /**
+ * Internal message to receive stats from StatsActor.
+ * @author Dara Cunningham
+ */
+ private static final class StatsActorResponse implements Message {
+ final StatsActor.StatsResult result;
+ final ActorRef originalReplyTo;
+
+ StatsActorResponse(StatsActor. StatsResult result, ActorRef originalReplyTo) {
+ this.result = result;
+ this.originalReplyTo = originalReplyTo;
+ }
+ }
+
/**
* Internal message to handle WebSocket disconnection
* @author Nattamon Paiboon
@@ -252,6 +266,7 @@ private Behavior behavior() {
.onMessage(ReadabilityResult.class, this::onReadabilityResult)
.onMessage(GetStats.class, this::onGetStats)
.onMessage(StatsResult.class, this::onStatsResult)
+ .onMessage(StatsActorResponse.class, this::onStatsActorResponse)
.onMessage(InternalStop.class, this::onInternalStop)
.build();
}
@@ -371,6 +386,23 @@ private Behavior onGetStats(GetStats msg) {
return Behaviors.same();
}
+ /**
+ * Handles response from StatsActor and forwards to original requester.
+ * @author Dara Cunningham
+ */
+ private Behavior onStatsActorResponse(StatsActorResponse msg) {
+ context.getLog(). info("Received stats from StatsActor for query: {}", msg.result.query());
+
+ // Forward to original requester (SupervisorActor)
+ msg.originalReplyTo.tell(new StatsResult(
+ msg.result. found(),
+ msg.result.query(),
+ msg.result.sortedCounts()
+ ));
+
+ return Behaviors.same();
+ }
+
/**
* Handles StatsResult from StatsActor and forwards to original requester
* @author Dara Cunningham
diff --git a/app/controllers/HomeController.java b/app/controllers/HomeController.java
index 6b02ac5..93b2042 100644
--- a/app/controllers/HomeController.java
+++ b/app/controllers/HomeController.java
@@ -240,7 +240,7 @@ public CompletionStage stats(Http.Request request, String rawQuery) {
// Ask SupervisorActor for stats
CompletionStage statsFuture = AskPattern.ask(
- userParentActor,
+ supervisorActor,
(ActorRef replyTo) ->
new SupervisorActor.GetStats(token, rawQuery, replyTo),
Duration.ofSeconds(10),
From 8efd4a74e128cd00ff823f03927f582a3e740128 Mon Sep 17 00:00:00 2001
From: Dara C
Date: Sat, 29 Nov 2025 19:12:04 -0500
Subject: [PATCH 05/11] Added unit tests for StatsActor
---
app/actors/StatsActor.java | 6 +-
test/actors/StatsActorTest.java | 442 ++++++++++++++++++++++++++++++++
2 files changed, 445 insertions(+), 3 deletions(-)
create mode 100644 test/actors/StatsActorTest.java
diff --git a/app/actors/StatsActor.java b/app/actors/StatsActor.java
index c374260..90277e5 100644
--- a/app/actors/StatsActor.java
+++ b/app/actors/StatsActor.java
@@ -96,12 +96,12 @@ private Behavior behavior() {
}
private Behavior onComputeStats(ComputeStats msg) {
- context.getLog().info("Computing stats for query: {} ({} articles)",
- msg.query, msg.articles.size());
-
ActorRef self = context.getSelf();
try {
+ context.getLog().info("Computing stats for query: {} ({} articles)",
+ msg.query, msg.articles.size());
+
// Compute word statistics using existing Stats utility
List> sortedCounts = Stats.calculateSortedCounts(msg.articles);
diff --git a/test/actors/StatsActorTest.java b/test/actors/StatsActorTest.java
new file mode 100644
index 0000000..249692c
--- /dev/null
+++ b/test/actors/StatsActorTest.java
@@ -0,0 +1,442 @@
+package actors;
+
+import models.Article;
+import models.Stats;
+import org.apache.pekko.actor.testkit.typed.javadsl.ActorTestKit;
+import org.apache.pekko.actor.testkit.typed.javadsl.TestProbe;
+import org.apache.pekko.actor.typed.ActorRef;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+import org.mockito.MockedStatic;
+
+/**
+ * Unit tests for StatsActor using Pekko TestKit.
+ * Tests cover all message handlers for 100% code coverage.
+ * @author Dara Cunningham
+ */
+public class StatsActorTest {
+
+ private static ActorTestKit testKit;
+
+ @BeforeClass
+ public static void setupClass() {
+ testKit = ActorTestKit.create();
+ }
+
+ @AfterClass
+ public static void teardownClass() {
+ testKit.shutdownTestKit();
+ }
+
+ /**
+ * Test that StatsActor can be created successfully.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testStatsActorCreation() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ assertNotNull("StatsActor should be created", statsActor);
+ }
+
+ /**
+ * Test successful computation of stats with valid articles.
+ * Verifies the actor sends a StatsResult with found=true and sorted word counts.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsSuccess() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ // Create mock articles with descriptions
+ List articles = createMockArticles(5, "test description with words");
+
+ // Send ComputeStats message
+ statsActor.tell(new StatsActor.ComputeStats("testQuery", articles, userActorProbe.ref()));
+
+ // Expect StatsResult
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertEquals("Query should match", "testQuery", result.query());
+ assertNotNull("Sorted counts should not be null", result.sortedCounts());
+ }
+
+ /**
+ * Test computation with empty article list.
+ * Should return found=true with empty sorted counts.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsEmptyArticles() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ List articles = new ArrayList<>();
+
+ statsActor.tell(new StatsActor.ComputeStats("emptyQuery", articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertEquals("Query should match", "emptyQuery", result.query());
+ assertTrue("Sorted counts should be empty", result.sortedCounts().isEmpty());
+ }
+
+ /**
+ * Test computation with articles containing various word frequencies.
+ * Verifies the word counts are sorted correctly.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsWordFrequency() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ // Create articles with repeated words to test frequency counting
+ List articles = new ArrayList<>();
+ Article article1 = mock(Article.class);
+ when(article1.getDescription()).thenReturn("hello world hello");
+ when(article1.getTitle()).thenReturn("Test Article");
+ articles.add(article1);
+
+ Article article2 = mock(Article.class);
+ when(article2.getDescription()).thenReturn("hello world test");
+ when(article2.getTitle()).thenReturn("Test Article 2");
+ articles.add(article2);
+
+ statsActor.tell(new StatsActor.ComputeStats("frequencyQuery", articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertFalse("Sorted counts should not be empty", result.sortedCounts().isEmpty());
+
+ // Verify "hello" appears most frequently (3 times)
+ if (!result.sortedCounts().isEmpty()) {
+ Map.Entry topWord = result.sortedCounts().get(0);
+ assertEquals("Top word should be 'hello'", "hello", topWord.getKey());
+ assertEquals("Count should be 3", Integer.valueOf(3), topWord.getValue());
+ }
+ }
+
+ /**
+ * Test computation with a single article.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsSingleArticle() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ List articles = createMockArticles(1, "single article description here");
+
+ statsActor.tell(new StatsActor.ComputeStats("singleQuery", articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertEquals("Query should match", "singleQuery", result.query());
+ assertFalse("Sorted counts should not be empty", result.sortedCounts().isEmpty());
+ }
+
+ /**
+ * Test computation with many articles to verify scalability.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsManyArticles() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ List articles = createMockArticles(50, "comprehensive test description with multiple words");
+
+ statsActor.tell(new StatsActor.ComputeStats("manyArticlesQuery", articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(5)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertEquals("Query should match", "manyArticlesQuery", result.query());
+ }
+
+ /**
+ * Test that multiple ComputeStats requests are handled independently.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testMultipleConcurrentComputeStats() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe probe1 = testKit.createTestProbe();
+ TestProbe probe2 = testKit.createTestProbe();
+ TestProbe probe3 = testKit.createTestProbe();
+
+ List articles1 = createMockArticles(3, "first query articles");
+ List articles2 = createMockArticles(4, "second query articles");
+ List articles3 = createMockArticles(5, "third query articles");
+
+ statsActor.tell(new StatsActor.ComputeStats("query1", articles1, probe1.ref()));
+ statsActor.tell(new StatsActor.ComputeStats("query2", articles2, probe2.ref()));
+ statsActor.tell(new StatsActor.ComputeStats("query3", articles3, probe3.ref()));
+
+ UserActor.StatsResult result1 = probe1.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3));
+ UserActor.StatsResult result2 = probe2.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3));
+ UserActor.StatsResult result3 = probe3.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3));
+
+ assertEquals("First result query should match", "query1", result1.query());
+ assertEquals("Second result query should match", "query2", result2.query());
+ assertEquals("Third result query should match", "query3", result3.query());
+
+ assertTrue("All results should be found", result1.found() && result2.found() && result3.found());
+ }
+
+ /**
+ * Test computation with articles having null descriptions.
+ * Should handle gracefully without throwing exception.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsNullDescription() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ List articles = new ArrayList<>();
+ Article article = mock(Article.class);
+ when(article.getDescription()).thenReturn(null);
+ when(article.getTitle()).thenReturn("Title Only");
+ articles.add(article);
+
+ statsActor.tell(new StatsActor.ComputeStats("nullDescQuery", articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ // Should complete without error, either found or not depending on implementation
+ assertNotNull("Result should not be null", result);
+ assertEquals("Query should match", "nullDescQuery", result.query());
+ }
+
+ /**
+ * Test computation with articles having empty descriptions.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsEmptyDescription() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ List articles = new ArrayList<>();
+ Article article = mock(Article.class);
+ when(article.getDescription()).thenReturn("");
+ when(article.getTitle()).thenReturn("Empty Description Article");
+ articles.add(article);
+
+ statsActor.tell(new StatsActor.ComputeStats("emptyDescQuery", articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertEquals("Query should match", "emptyDescQuery", result.query());
+ }
+
+ /**
+ * Test computation with special characters in article descriptions.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsSpecialCharacters() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ List articles = new ArrayList<>();
+ Article article = mock(Article.class);
+ when(article.getDescription()).thenReturn("hello! world? test@#$% special-chars");
+ when(article.getTitle()).thenReturn("Special Chars Article");
+ articles.add(article);
+
+ statsActor.tell(new StatsActor.ComputeStats("specialQuery", articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertNotNull("Sorted counts should not be null", result.sortedCounts());
+ }
+
+ /**
+ * Test that StatsResult record is properly constructed.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testStatsResultRecord() {
+ List> sortedCounts = List.of(
+ Map.entry("word1", 5),
+ Map.entry("word2", 3)
+ );
+
+ StatsActor.StatsResult result = new StatsActor.StatsResult(true, "testQuery", sortedCounts);
+
+ assertTrue("Found should be true", result.found());
+ assertEquals("Query should match", "testQuery", result.query());
+ assertEquals("Sorted counts size should match", 2, result.sortedCounts().size());
+ }
+
+ /**
+ * Test ComputeStats message construction and field access.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsMessageConstruction() {
+ TestProbe probe = testKit.createTestProbe();
+ List articles = createMockArticles(2, "test");
+
+ StatsActor.ComputeStats msg = new StatsActor.ComputeStats("queryTest", articles, probe.ref());
+
+ assertEquals("Query should match", "queryTest", msg.query);
+ assertEquals("Articles should match", articles, msg.articles);
+ assertEquals("ReplyTo should match", probe.ref(), msg.replyTo);
+ }
+
+ /**
+ * Test that actor continues processing after handling messages.
+ * Verifies Behaviors.same() is returned.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testActorContinuesAfterProcessing() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe probe1 = testKit.createTestProbe();
+ TestProbe probe2 = testKit.createTestProbe();
+
+ // Send first message
+ statsActor.tell(new StatsActor.ComputeStats("first", createMockArticles(2, "first"), probe1.ref()));
+ probe1.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3));
+
+ // Actor should still be alive and process second message
+ statsActor.tell(new StatsActor.ComputeStats("second", createMockArticles(2, "second"), probe2.ref()));
+ UserActor.StatsResult result = probe2.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3));
+
+ assertEquals("Second query should be processed", "second", result.query());
+ }
+
+ /**
+ * Test computation with articles containing mixed case words.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsMixedCaseWords() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ List articles = new ArrayList<>();
+ Article article = mock(Article.class);
+ when(article.getDescription()).thenReturn("Hello HELLO hello HeLLo");
+ when(article.getTitle()).thenReturn("Mixed Case");
+ articles.add(article);
+
+ statsActor.tell(new StatsActor.ComputeStats("mixedCaseQuery", articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertFalse("Sorted counts should not be empty", result.sortedCounts().isEmpty());
+ }
+
+ /**
+ * Test that query with spaces is handled correctly.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsQueryWithSpaces() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ List articles = createMockArticles(3, "test description");
+ String queryWithSpaces = "climate change news";
+
+ statsActor.tell(new StatsActor.ComputeStats(queryWithSpaces, articles, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertTrue("Result should indicate found", result.found());
+ assertEquals("Query should match including spaces", queryWithSpaces, result.query());
+ }
+
+ /**
+ * Test ComputeFailed path when Stats.calculateSortedCounts throws an exception.
+ * Verifies onComputeFailed is called and sends StatsResult with found=false.
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testComputeStatsFailure() {
+ ActorRef statsActor = testKit.spawn(StatsActor.create());
+ TestProbe userActorProbe = testKit.createTestProbe();
+
+ // Pass null to trigger NullPointerException inside the actor
+ statsActor.tell(new StatsActor.ComputeStats("failQuery", null, userActorProbe.ref()));
+
+ UserActor.StatsResult result = userActorProbe.expectMessageClass(
+ UserActor.StatsResult.class,
+ Duration.ofSeconds(3)
+ );
+
+ assertFalse("Result should indicate not found", result.found());
+ }
+
+ // === Helper Methods ===
+
+ /**
+ * Create a list of mock Article objects with the given description.
+ * @param count number of articles to create
+ * @param description description text for each article
+ * @return list of mock articles
+ */
+ private List createMockArticles(int count, String description) {
+ List articles = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ Article article = mock(Article.class);
+ when(article.getTitle()).thenReturn("Article " + i);
+ when(article.getDescription()).thenReturn(description + " " + i);
+ when(article.getUrl()).thenReturn("https://example.com/article-" + i);
+ when(article.getSourceName()).thenReturn("Test Source");
+ articles.add(article);
+ }
+ return articles;
+ }
+}
From b7689ad533b8223a7a05eacc33dd99ce2afbbbe1 Mon Sep 17 00:00:00 2001
From: Dara Cunningham
Date: Sat, 29 Nov 2025 19:40:00 -0500
Subject: [PATCH 06/11] Fixed testStats()
---
test/controllers/HomeControllerTest.java | 43 ++++++++++--------------
1 file changed, 17 insertions(+), 26 deletions(-)
diff --git a/test/controllers/HomeControllerTest.java b/test/controllers/HomeControllerTest.java
index 6244dfa..c05996e 100644
--- a/test/controllers/HomeControllerTest.java
+++ b/test/controllers/HomeControllerTest.java
@@ -44,12 +44,10 @@
import play.libs.ws.WSResponse;
import java.time.Duration;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.UUID;
+import java.util.*;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
-import java.util.Optional;
+
import static org.mockito.Mockito.*;
import static org.junit.Assert.*;
import static play.test.Helpers.*;
@@ -279,32 +277,25 @@ public void testSource_Failure_ApiError() {
* Verifies the session token is used to read history from cache.
* @author Dara Cunningham
*/
- @Test
- public void testStats() {
- // First perform a search to populate history/cache for the session
- String token = "unit-test-token";
- Search mockSearch = mock(Search.class);
- when(mockSearch.getRawQuery()).thenReturn("testQuery");
- when(mockSearch.getSortBy()).thenReturn("popularity");
- when(mockSearch.getResults()).thenReturn(new ArrayList<>());
-
- ArrayList cachedHistory = new ArrayList<>();
- cachedHistory.add(mockSearch);
- when(mockCache.get(eq(token)))
- .thenReturn(CompletableFuture.completedFuture(Optional.of(cachedHistory)));
-
- // Then call stats for the same query using the same session token
+ @Test
+ public void testStats() throws Exception {
+ String query = "testQuery";
+
+ // This is an integration test - it requires the full actor system to be running
+ // The test verifies the stats route returns 404 when no search has been performed
+
Http.RequestBuilder statsReq = new Http.RequestBuilder()
.method(GET)
- .uri("/search/stats?rawQuery=testQuery")
- .session("user_token", token);
+ .uri("/search/stats?rawQuery=" + query + "&sessionId=new-session-id");
- Result statsResult = route(app, statsReq);
+ Result statsResult = route(app, statsReq);
assertNotNull(statsResult);
- assertEquals(OK, statsResult.status());
- assertTrue(contentAsString(statsResult).contains("testQuery")); // or whatever the view shows
- verify(mockCache, times(1)).get(token);
- }
+
+ // Expect 404 because no search was performed for this session
+ assertEquals(NOT_FOUND, statsResult.status());
+ assertTrue(contentAsString(statsResult).contains("Search not found"));
+ }
+
/**
* Test that a WebSocket connection to the server can be successfully established and receives messages.
From 37e68a5d591b49ab04069c86485fdcd3bc2f0a70 Mon Sep 17 00:00:00 2001
From: Dara Cunningham
Date: Sat, 29 Nov 2025 20:02:03 -0500
Subject: [PATCH 07/11] Added tets to SupervisorActor
---
test/actors/SupervisorActorTest.java | 101 +++++++++++++++++++++++++++
1 file changed, 101 insertions(+)
diff --git a/test/actors/SupervisorActorTest.java b/test/actors/SupervisorActorTest.java
index 4c48450..1038d86 100644
--- a/test/actors/SupervisorActorTest.java
+++ b/test/actors/SupervisorActorTest.java
@@ -259,4 +259,105 @@ public void testSameSessionDifferentProbes() {
Flow flow2 = probe2.receiveMessage(Duration.ofSeconds(3));
assertNotNull("Second probe should receive flow", flow2);
}
+
+ /**
+ * SupervisorActor handles GetStats request and returns stats results
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testGetStatsWithExistingUserActor() {
+ String sessionId = "stats-session-" + System.currentTimeMillis();
+
+ // First, create a UserActor for this session
+ TestProbe> flowProbe = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.Create(sessionId, flowProbe.ref()));
+ Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("UserActor should be created", flow);
+
+ // Now request stats for this session
+ TestProbe statsProbe = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.GetStats(sessionId, "test-query", statsProbe.ref()));
+
+ SupervisorActor.StatsResponse response = statsProbe.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("Should receive stats response", response);
+ assertEquals("Query should match", "test-query", response.query());
+ // UserActor may return empty stats if no searches performed, but should respond
+ assertNotNull("Stats list should not be null", response.sortedCounts());
+ }
+
+ /**
+ * SupervisorActor handles GetStats request for non-existent session
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testGetStatsWithNonExistentUserActor() {
+ String nonExistentSessionId = "nonexistent-session-" + System.currentTimeMillis();
+
+ TestProbe statsProbe = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.GetStats(nonExistentSessionId, "test-query", statsProbe.ref()));
+
+ SupervisorActor.StatsResponse response = statsProbe.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("Should receive stats response", response);
+ assertFalse("Stats should not be found", response.found());
+ assertEquals("Query should match", "test-query", response.query());
+ assertTrue("Stats list should be empty", response.sortedCounts().isEmpty());
+ }
+
+ /**
+ * SupervisorActor handles multiple stats requests for same session
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testMultipleStatsRequests() {
+ String sessionId = "multi-stats-session-" + System.currentTimeMillis();
+
+ // Create UserActor
+ TestProbe> flowProbe = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.Create(sessionId, flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Request stats multiple times
+ TestProbe statsProbe1 = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.GetStats(sessionId, "query1", statsProbe1.ref()));
+ SupervisorActor.StatsResponse response1 = statsProbe1.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("First stats response should be received", response1);
+ assertEquals("First query should match", "query1", response1.query());
+
+ TestProbe statsProbe2 = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.GetStats(sessionId, "query2", statsProbe2.ref()));
+ SupervisorActor.StatsResponse response2 = statsProbe2.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("Second stats response should be received", response2);
+ assertEquals("Second query should match", "query2", response2.query());
+ }
+
+ /**
+ * SupervisorActor handles stats requests for different sessions independently
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testStatsRequestsForDifferentSessions() {
+ String session1 = "stats-session-1-" + System.currentTimeMillis();
+ String session2 = "stats-session-2-" + System.currentTimeMillis();
+
+ // Create UserActors for both sessions
+ TestProbe> flowProbe1 = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.Create(session1, flowProbe1.ref()));
+ flowProbe1.receiveMessage(Duration.ofSeconds(3));
+
+ TestProbe> flowProbe2 = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.Create(session2, flowProbe2.ref()));
+ flowProbe2.receiveMessage(Duration.ofSeconds(3));
+
+ // Request stats for both sessions
+ TestProbe statsProbe1 = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.GetStats(session1, "query-for-session-1", statsProbe1.ref()));
+ SupervisorActor.StatsResponse response1 = statsProbe1.receiveMessage(Duration.ofSeconds(3));
+ assertEquals("Session 1 query should match", "query-for-session-1", response1.query());
+
+ TestProbe statsProbe2 = testKit.createTestProbe();
+ supervisorActor.tell(new SupervisorActor.GetStats(session2, "query-for-session-2", statsProbe2.ref()));
+ SupervisorActor.StatsResponse response2 = statsProbe2.receiveMessage(Duration.ofSeconds(3));
+ assertEquals("Session 2 query should match", "query-for-session-2", response2.query());
+ }
+
}
From 73ebb5020f6760d9ed61d40685331b65f4bc08fd Mon Sep 17 00:00:00 2001
From: Dara Cunningham
Date: Sat, 29 Nov 2025 20:21:32 -0500
Subject: [PATCH 08/11] UserActor tests
---
test/actors/UserActorTest.java | 184 +++++++++++++++++++++++++++++++++
1 file changed, 184 insertions(+)
diff --git a/test/actors/UserActorTest.java b/test/actors/UserActorTest.java
index 8929785..6d217cc 100644
--- a/test/actors/UserActorTest.java
+++ b/test/actors/UserActorTest.java
@@ -943,6 +943,190 @@ public void testOnNewsResultsDifferentSortBySameQuery() throws Exception {
assertEquals("Should have 2 separate searches (different sortBy)", 2, history2.size());
}
+ /**
+ * UserActor handles GetStats request for existing search
+ * Tests the else branch of onGetStats where search is found
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testGetStatsWithExistingSearch() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Simulate a search result being added to history
+ Search search = createMockSearch("test-query", "publishedAt", 1, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+
+ // Now request stats for this query
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("test-query", statsProbe.ref()));
+
+ // The UserActor should delegate to StatsActor (we don't get immediate response)
+ // In real scenario, StatsActor would respond back
+ // For now, verify no immediate response (delegation happened)
+ }
+
+ /**
+ * UserActor handles GetStats request for non-existent search
+ * Tests the if branch of onGetStats where search is not found
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testGetStatsWithNonExistentSearch() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Request stats for a query that doesn't exist
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("nonexistent-query", statsProbe.ref()));
+
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("Should receive stats result", result);
+ assertFalse("Stats should not be found", result.found());
+ assertEquals("Query should match", "nonexistent-query", result.query());
+ assertTrue("Stats list should be empty", result.sortedCounts().isEmpty());
+ }
+
+ /**
+ * UserActor handles StatsResult from StatsActor via onStatsResult
+ * Tests the legacy direct StatsResult message handling
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testOnStatsResultHandling() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Add a search to history
+ Search search = createMockSearch("stats-query", "publishedAt", 2, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+
+ // Request stats - this creates a pending request
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("stats-query", statsProbe.ref()));
+
+ // Simulate StatsResult coming back (this would normally come from StatsActor)
+ List> counts = new ArrayList<>();
+ counts.add(new java.util.AbstractMap.SimpleEntry<>("test", 2));
+ counts.add(new java.util.AbstractMap.SimpleEntry<>("words", 1));
+
+ UserActor.StatsResult statsResult = new UserActor.StatsResult(true, "stats-query", counts);
+ userActor.tell(statsResult);
+
+ // Should forward to the original requester
+ UserActor.StatsResult response = statsProbe.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("Should receive forwarded stats result", response);
+ assertTrue("Stats should be found", response.found());
+ assertEquals("Query should match", "stats-query", response.query());
+ assertFalse("Stats counts should not be empty", response.sortedCounts().isEmpty());
+ }
+
+ /**
+ * UserActor handles StatsResult without pending request
+ * Tests onStatsResult when no one is waiting for the response
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testOnStatsResultWithoutPendingRequest() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Send StatsResult without first requesting stats
+ UserActor.StatsResult statsResult = new UserActor.StatsResult(
+ true, "orphan-query", new ArrayList<>()
+ );
+
+ // This should be handled gracefully (logged but not crash)
+ userActor.tell(statsResult);
+
+ // Actor should remain functional
+ assertTrue("Actor should still be alive", userActor != null);
+ }
+
+ /**
+ * UserActor handles multiple GetStats requests for different queries
+ * Tests that pending stats requests are tracked correctly
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testMultipleGetStatsRequests() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Add two different searches
+ Search search1 = createMockSearch("query1", "publishedAt", 1, 0);
+ userActor.tell(new UserActor.NewsResults(search1));
+
+ Search search2 = createMockSearch("query2", "publishedAt", 1, 0);
+ userActor.tell(new UserActor.NewsResults(search2));
+
+ // Request stats for both
+ TestProbe statsProbe1 = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("query1", statsProbe1.ref()));
+
+ TestProbe statsProbe2 = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("query2", statsProbe2.ref()));
+
+ // Both should be tracked in pendingStatsRequests
+ // (actual responses would come from StatsActor in integration)
+ }
+
+ /**
+ * UserActor properly delegates stats computation to StatsActor
+ * Tests the full flow: GetStats -> delegation -> StatsActorResponse
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testStatsActorDelegation() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Add a search with articles
+ Search search = createMockSearch("delegation-query", "publishedAt", 3, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+
+ // Request stats - UserActor should delegate to its internal StatsActor
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("delegation-query", statsProbe.ref()));
+
+ // The internal StatsActor should process and respond
+ // This tests the StatsActorResponse internal message flow
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5));
+ assertNotNull("Should receive stats result from StatsActor", result);
+ assertTrue("Stats should be found", result.found());
+ assertEquals("Query should match", "delegation-query", result.query());
+ }
+
+ /**
+ * UserActor handles stats for empty article list
+ * Tests edge case where search exists but has no articles
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testGetStatsWithEmptyArticleList() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Add a search with no articles
+ Search search = createMockSearch("empty-query", "publishedAt", 0, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("empty-query", statsProbe.ref()));
+
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("Should receive stats result", result);
+ assertTrue("Stats should be found even for empty list", result.found());
+ assertEquals("Query should match", "empty-query", result.query());
+ }
+
// === Helper Methods ===
/**
From 02abeebe19bf6c9029cdbedfc5105ee80e63d9c1 Mon Sep 17 00:00:00 2001
From: Dara C
Date: Sat, 29 Nov 2025 21:06:54 -0500
Subject: [PATCH 09/11] Unit test coverage for UserActor statistics
---
test/actors/UserActorTest.java | 576 +++++++++++++++++++++++----------
1 file changed, 397 insertions(+), 179 deletions(-)
diff --git a/test/actors/UserActorTest.java b/test/actors/UserActorTest.java
index 6d217cc..a8daaec 100644
--- a/test/actors/UserActorTest.java
+++ b/test/actors/UserActorTest.java
@@ -24,6 +24,7 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.*;
@@ -943,189 +944,406 @@ public void testOnNewsResultsDifferentSortBySameQuery() throws Exception {
assertEquals("Should have 2 separate searches (different sortBy)", 2, history2.size());
}
- /**
- * UserActor handles GetStats request for existing search
- * Tests the else branch of onGetStats where search is found
- * @author Dara Cunningham
- */
- @Test
- public void testGetStatsWithExistingSearch() {
- TestProbe> flowProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.Connect(flowProbe.ref()));
- flowProbe.receiveMessage(Duration.ofSeconds(3));
-
- // Simulate a search result being added to history
- Search search = createMockSearch("test-query", "publishedAt", 1, 0);
- userActor.tell(new UserActor.NewsResults(search));
-
- // Now request stats for this query
- TestProbe statsProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.GetStats("test-query", statsProbe.ref()));
-
- // The UserActor should delegate to StatsActor (we don't get immediate response)
- // In real scenario, StatsActor would respond back
- // For now, verify no immediate response (delegation happened)
- }
-
- /**
- * UserActor handles GetStats request for non-existent search
- * Tests the if branch of onGetStats where search is not found
- * @author Dara Cunningham
- */
- @Test
- public void testGetStatsWithNonExistentSearch() {
- TestProbe> flowProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.Connect(flowProbe.ref()));
- flowProbe.receiveMessage(Duration.ofSeconds(3));
-
- // Request stats for a query that doesn't exist
- TestProbe statsProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.GetStats("nonexistent-query", statsProbe.ref()));
-
- UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(3));
- assertNotNull("Should receive stats result", result);
- assertFalse("Stats should not be found", result.found());
- assertEquals("Query should match", "nonexistent-query", result.query());
- assertTrue("Stats list should be empty", result.sortedCounts().isEmpty());
- }
+ /**
+ * UserActor handles GetStats request for existing search
+ * Tests the else branch of onGetStats where search is found
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testGetStatsWithExistingSearch() throws Exception {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Setup TestSource and TestSink
+ Pair, TestSubscriber.Probe> probes =
+ TestSource.create(testKit.system())
+ .via(flow)
+ .toMat(TestSink.create(testKit.system()), Keep.both())
+ .run(testKit.system());
+
+ TestPublisher.Probe pub = probes.first();
+ TestSubscriber.Probe sub = probes.second();
+
+ sub.request(10);
+
+ // Send a query to create a search instance
+ JsonNode queryJson = Json.newObject()
+ .put("query", "test-query")
+ .put("sortBy", "publishedAt");
+ pub.sendNext(queryJson);
+
+ // Wait for WatchQuery
+ newsActorProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Send search result to add to history
+ Search search = createMockSearch("test-query", "publishedAt", 5, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+ simulateReadabilityResponse();
+
+ // Skip to get history update
+ for (int i = 0; i < 5; i++) {
+ JsonNode msg = sub.expectNext();
+ if (msg.isArray()) break;
+ }
+
+ // Now request stats for this query
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("test-query", statsProbe.ref()));
+
+ // Should receive stats result from StatsActor delegation
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5));
+ assertNotNull("Should receive stats result", result);
+ assertTrue("Stats should be found", result.found());
+ assertEquals("Query should match", "test-query", result.query());
+ }
/**
- * UserActor handles StatsResult from StatsActor via onStatsResult
- * Tests the legacy direct StatsResult message handling
- * @author Dara Cunningham
- */
- @Test
- public void testOnStatsResultHandling() {
- TestProbe> flowProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.Connect(flowProbe.ref()));
- flowProbe.receiveMessage(Duration.ofSeconds(3));
-
- // Add a search to history
- Search search = createMockSearch("stats-query", "publishedAt", 2, 0);
- userActor.tell(new UserActor.NewsResults(search));
-
- // Request stats - this creates a pending request
- TestProbe statsProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.GetStats("stats-query", statsProbe.ref()));
-
- // Simulate StatsResult coming back (this would normally come from StatsActor)
- List> counts = new ArrayList<>();
- counts.add(new java.util.AbstractMap.SimpleEntry<>("test", 2));
- counts.add(new java.util.AbstractMap.SimpleEntry<>("words", 1));
-
- UserActor.StatsResult statsResult = new UserActor.StatsResult(true, "stats-query", counts);
- userActor.tell(statsResult);
-
- // Should forward to the original requester
- UserActor.StatsResult response = statsProbe.receiveMessage(Duration.ofSeconds(3));
- assertNotNull("Should receive forwarded stats result", response);
- assertTrue("Stats should be found", response.found());
- assertEquals("Query should match", "stats-query", response.query());
- assertFalse("Stats counts should not be empty", response.sortedCounts().isEmpty());
- }
+ * UserActor handles GetStats request for non-existent search
+ * Tests the if branch of onGetStats where search is not found
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testGetStatsWithNonExistentSearch() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Request stats for a query that doesn't exist in history
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("nonexistent-query", statsProbe.ref()));
+
+ // Should receive immediate response with found=false
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("Should receive stats result", result);
+ assertFalse("Stats should not be found", result.found());
+ assertEquals("Query should match", "nonexistent-query", result.query());
+ assertTrue("Stats list should be empty", result.sortedCounts().isEmpty());
+ }
/**
- * UserActor handles StatsResult without pending request
- * Tests onStatsResult when no one is waiting for the response
- * @author Dara Cunningham
- */
- @Test
- public void testOnStatsResultWithoutPendingRequest() {
- TestProbe> flowProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.Connect(flowProbe.ref()));
- flowProbe.receiveMessage(Duration.ofSeconds(3));
-
- // Send StatsResult without first requesting stats
- UserActor.StatsResult statsResult = new UserActor.StatsResult(
- true, "orphan-query", new ArrayList<>()
- );
-
- // This should be handled gracefully (logged but not crash)
- userActor.tell(statsResult);
-
- // Actor should remain functional
- assertTrue("Actor should still be alive", userActor != null);
- }
-
- /**
- * UserActor handles multiple GetStats requests for different queries
- * Tests that pending stats requests are tracked correctly
- * @author Dara Cunningham
- */
- @Test
- public void testMultipleGetStatsRequests() {
- TestProbe> flowProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.Connect(flowProbe.ref()));
- flowProbe.receiveMessage(Duration.ofSeconds(3));
-
- // Add two different searches
- Search search1 = createMockSearch("query1", "publishedAt", 1, 0);
- userActor.tell(new UserActor.NewsResults(search1));
-
- Search search2 = createMockSearch("query2", "publishedAt", 1, 0);
- userActor.tell(new UserActor.NewsResults(search2));
-
- // Request stats for both
- TestProbe statsProbe1 = testKit.createTestProbe();
- userActor.tell(new UserActor.GetStats("query1", statsProbe1.ref()));
-
- TestProbe statsProbe2 = testKit.createTestProbe();
- userActor.tell(new UserActor.GetStats("query2", statsProbe2.ref()));
-
- // Both should be tracked in pendingStatsRequests
- // (actual responses would come from StatsActor in integration)
- }
-
- /**
- * UserActor properly delegates stats computation to StatsActor
- * Tests the full flow: GetStats -> delegation -> StatsActorResponse
- * @author Dara Cunningham
- */
- @Test
- public void testStatsActorDelegation() {
- TestProbe> flowProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.Connect(flowProbe.ref()));
- flowProbe.receiveMessage(Duration.ofSeconds(3));
-
- // Add a search with articles
- Search search = createMockSearch("delegation-query", "publishedAt", 3, 0);
- userActor.tell(new UserActor.NewsResults(search));
-
- // Request stats - UserActor should delegate to its internal StatsActor
- TestProbe statsProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.GetStats("delegation-query", statsProbe.ref()));
-
- // The internal StatsActor should process and respond
- // This tests the StatsActorResponse internal message flow
- UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5));
- assertNotNull("Should receive stats result from StatsActor", result);
- assertTrue("Stats should be found", result.found());
- assertEquals("Query should match", "delegation-query", result.query());
- }
-
- /**
- * UserActor handles stats for empty article list
- * Tests edge case where search exists but has no articles
- * @author Dara Cunningham
- */
- @Test
- public void testGetStatsWithEmptyArticleList() {
- TestProbe> flowProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.Connect(flowProbe.ref()));
- flowProbe.receiveMessage(Duration.ofSeconds(3));
-
- // Add a search with no articles
- Search search = createMockSearch("empty-query", "publishedAt", 0, 0);
- userActor.tell(new UserActor.NewsResults(search));
-
- TestProbe statsProbe = testKit.createTestProbe();
- userActor.tell(new UserActor.GetStats("empty-query", statsProbe.ref()));
-
- UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(3));
- assertNotNull("Should receive stats result", result);
- assertTrue("Stats should be found even for empty list", result.found());
- assertEquals("Query should match", "empty-query", result.query());
- }
+ * UserActor handles StatsResult from StatsActor and forwards to original requester
+ * Tests onStatsResult when there is a pending request
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testOnStatsResultHandling() throws Exception {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Setup TestSource and TestSink
+ Pair, TestSubscriber.Probe> probes =
+ TestSource.create(testKit.system())
+ .via(flow)
+ .toMat(TestSink.create(testKit.system()), Keep.both())
+ .run(testKit.system());
+
+ TestPublisher.Probe pub = probes.first();
+ TestSubscriber.Probe sub = probes.second();
+
+ sub.request(10);
+
+ // Send a query to create a search instance
+ JsonNode queryJson = Json.newObject()
+ .put("query", "stats-query")
+ .put("sortBy", "publishedAt");
+ pub.sendNext(queryJson);
+
+ // Wait for WatchQuery
+ newsActorProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Add a search to history
+ Search search = createMockSearch("stats-query", "publishedAt", 5, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+ simulateReadabilityResponse();
+
+ // Skip to get history update
+ for (int i = 0; i < 5; i++) {
+ JsonNode msg = sub.expectNext();
+ if (msg.isArray()) break;
+ }
+
+ // Request stats - this creates a pending request and delegates to StatsActor
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("stats-query", statsProbe.ref()));
+
+ // StatsActor will compute and send back StatsResult
+ // UserActor's onStatsResult will forward to the original requester
+ UserActor.StatsResult response = statsProbe.receiveMessage(Duration.ofSeconds(5));
+ assertNotNull("Should receive forwarded stats result", response);
+ assertTrue("Stats should be found", response.found());
+ assertEquals("Query should match", "stats-query", response.query());
+ }
+
+ /**
+ * UserActor handles StatsResult without pending request
+ * Tests onStatsResult when no one is waiting for the response (logs warning)
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testOnStatsResultWithoutPendingRequest() {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Send StatsResult directly without first requesting stats
+ // This simulates an orphan response (e.g., late arrival after timeout)
+ List> counts = new ArrayList<>();
+ counts.add(new java.util.AbstractMap.SimpleEntry<>("orphan", 1));
+
+ UserActor.StatsResult statsResult = new UserActor.StatsResult(
+ true, "orphan-query", counts
+ );
+
+ // This should be handled gracefully (logged warning but no crash)
+ userActor.tell(statsResult);
+
+ // Verify actor is still functional by sending another message
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("another-query", statsProbe.ref()));
+
+ // Should receive response for non-existent query
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(3));
+ assertNotNull("Actor should still be functional", result);
+ assertFalse("Query should not be found", result.found());
+ }
+
+ /**
+ * UserActor handles multiple GetStats requests for different queries
+ * Tests that pending stats requests are tracked correctly
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testMultipleGetStatsRequests() throws Exception {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Setup TestSource and TestSink
+ Pair, TestSubscriber.Probe> probes =
+ TestSource.create(testKit.system())
+ .via(flow)
+ .toMat(TestSink.create(testKit.system()), Keep.both())
+ .run(testKit.system());
+
+ TestPublisher.Probe pub = probes.first();
+ TestSubscriber.Probe sub = probes.second();
+
+ sub.request(15);
+
+ // Send two different queries
+ JsonNode query1 = Json.newObject()
+ .put("query", "query1")
+ .put("sortBy", "publishedAt");
+ JsonNode query2 = Json.newObject()
+ .put("query", "query2")
+ .put("sortBy", "publishedAt");
+
+ pub.sendNext(query1);
+ pub.sendNext(query2);
+
+ // Wait for WatchQuery messages
+ newsActorProbe.receiveMessage(Duration.ofSeconds(3));
+ newsActorProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Add two different searches
+ Search search1 = createMockSearch("query1", "publishedAt", 3, 0);
+ userActor.tell(new UserActor.NewsResults(search1));
+ simulateReadabilityResponse();
+
+ Search search2 = createMockSearch("query2", "publishedAt", 4, 0);
+ userActor.tell(new UserActor.NewsResults(search2));
+ simulateReadabilityResponse();
+
+ // Skip history updates
+ int arrayCount = 0;
+ for (int i = 0; i < 10 && arrayCount < 2; i++) {
+ JsonNode msg = sub.expectNext();
+ if (msg.isArray()) arrayCount++;
+ }
+
+ // Request stats for both queries
+ TestProbe statsProbe1 = testKit.createTestProbe();
+ TestProbe statsProbe2 = testKit.createTestProbe();
+
+ userActor.tell(new UserActor.GetStats("query1", statsProbe1.ref()));
+ userActor.tell(new UserActor.GetStats("query2", statsProbe2.ref()));
+
+ // Both should receive responses
+ UserActor.StatsResult result1 = statsProbe1.receiveMessage(Duration.ofSeconds(5));
+ UserActor.StatsResult result2 = statsProbe2.receiveMessage(Duration.ofSeconds(5));
+
+ assertNotNull("Should receive stats for query1", result1);
+ assertTrue("Stats1 should be found", result1.found());
+ assertEquals("Query1 should match", "query1", result1.query());
+
+ assertNotNull("Should receive stats for query2", result2);
+ assertTrue("Stats2 should be found", result2.found());
+ assertEquals("Query2 should match", "query2", result2.query());
+ }
+
+ /**
+ * UserActor properly delegates stats computation to StatsActor
+ * Tests the full flow: GetStats -> delegation -> StatsResult
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testStatsActorDelegation() throws Exception {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Setup TestSource and TestSink
+ Pair, TestSubscriber.Probe> probes =
+ TestSource.create(testKit.system())
+ .via(flow)
+ .toMat(TestSink.create(testKit.system()), Keep.both())
+ .run(testKit.system());
+
+ TestPublisher.Probe pub = probes.first();
+ TestSubscriber.Probe sub = probes.second();
+
+ sub.request(10);
+
+ // Send a query
+ JsonNode queryJson = Json.newObject()
+ .put("query", "delegation-query")
+ .put("sortBy", "publishedAt");
+ pub.sendNext(queryJson);
+
+ // Wait for WatchQuery
+ newsActorProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Add a search with articles that have content for stats
+ Search search = createMockSearch("delegation-query", "publishedAt", 5, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+ simulateReadabilityResponse();
+
+ // Skip to get history update
+ for (int i = 0; i < 5; i++) {
+ JsonNode msg = sub.expectNext();
+ if (msg.isArray()) break;
+ }
+
+ // Request stats - UserActor should delegate to its child StatsActor
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("delegation-query", statsProbe.ref()));
+
+ // The internal StatsActor should process and respond via onStatsResult
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5));
+ assertNotNull("Should receive stats result from StatsActor", result);
+ assertTrue("Stats should be found", result.found());
+ assertEquals("Query should match", "delegation-query", result.query());
+ // Stats list may be empty if mock articles don't have real content
+ assertNotNull("Stats counts should not be null", result.sortedCounts());
+ }
+
+ /**
+ * UserActor handles stats for empty article list
+ * Tests edge case where search exists but has no articles
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testGetStatsWithEmptyArticleList() throws Exception {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Setup TestSource and TestSink
+ Pair, TestSubscriber.Probe> probes =
+ TestSource.create(testKit.system())
+ .via(flow)
+ .toMat(TestSink.create(testKit.system()), Keep.both())
+ .run(testKit.system());
+
+ TestPublisher.Probe pub = probes.first();
+ TestSubscriber.Probe sub = probes.second();
+
+ sub.request(10);
+
+ // Send a query
+ JsonNode queryJson = Json.newObject()
+ .put("query", "empty-query")
+ .put("sortBy", "publishedAt");
+ pub.sendNext(queryJson);
+
+ // Wait for WatchQuery
+ newsActorProbe.receiveMessage(Duration.ofSeconds(3));
+
+ // Add a search with NO articles (empty list)
+ Search search = createMockSearch("empty-query", "publishedAt", 0, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+ simulateReadabilityResponse();
+
+ // Skip to get history update
+ for (int i = 0; i < 5; i++) {
+ JsonNode msg = sub.expectNext();
+ if (msg.isArray()) break;
+ }
+
+ // Request stats for query with empty article list
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("empty-query", statsProbe.ref()));
+
+ // Should still receive result (StatsActor handles empty list)
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5));
+ assertNotNull("Should receive stats result", result);
+ assertTrue("Stats should be found even for empty list", result.found());
+ assertEquals("Query should match", "empty-query", result.query());
+ assertTrue("Stats counts should be empty for no articles", result.sortedCounts().isEmpty());
+ }
+
+ /**
+ * Test that StatsActorResponse handler is not needed when using direct StatsResult
+ * This tests the removed onStatsActorResponse path - can be removed if that handler is removed
+ * @author Dara Cunningham
+ */
+ @Test
+ public void testStatsFlowCompletesSuccessfully() throws Exception {
+ TestProbe> flowProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.Connect(flowProbe.ref()));
+ Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3));
+
+ Pair, TestSubscriber.Probe> probes =
+ TestSource.create(testKit.system())
+ .via(flow)
+ .toMat(TestSink.create(testKit.system()), Keep.both())
+ .run(testKit.system());
+
+ TestPublisher.Probe pub = probes.first();
+ TestSubscriber.Probe sub = probes.second();
+
+ sub.request(10);
+
+ // Create and populate search
+ JsonNode queryJson = Json.newObject()
+ .put("query", "complete-flow")
+ .put("sortBy", "popularity");
+ pub.sendNext(queryJson);
+
+ newsActorProbe.receiveMessage(Duration.ofSeconds(3));
+
+ Search search = createMockSearch("complete-flow", "popularity", 10, 0);
+ userActor.tell(new UserActor.NewsResults(search));
+ simulateReadabilityResponse();
+
+ // Get history
+ for (int i = 0; i < 5; i++) {
+ JsonNode msg = sub.expectNext();
+ if (msg.isArray()) break;
+ }
+
+ // Request stats multiple times to ensure state is managed correctly
+ for (int round = 0; round < 3; round++) {
+ TestProbe statsProbe = testKit.createTestProbe();
+ userActor.tell(new UserActor.GetStats("complete-flow", statsProbe.ref()));
+
+ UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5));
+ assertNotNull("Round " + round + ": Should receive stats", result);
+ assertTrue("Round " + round + ": Stats should be found", result.found());
+ }
+ }
// === Helper Methods ===
From 993c1d0f085b72e32a8f1c69f41d853a58c1c34b Mon Sep 17 00:00:00 2001
From: Dara C
Date: Sat, 29 Nov 2025 21:20:15 -0500
Subject: [PATCH 10/11] Removed obsolete StatsActorResponse
---
app/actors/UserActor.java | 32 --------------------------------
1 file changed, 32 deletions(-)
diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java
index a4e3476..f42b1d6 100644
--- a/app/actors/UserActor.java
+++ b/app/actors/UserActor.java
@@ -111,20 +111,6 @@ public record StatsResult(
List> sortedCounts
) implements Message {}
- /**
- * Internal message to receive stats from StatsActor.
- * @author Dara Cunningham
- */
- private static final class StatsActorResponse implements Message {
- final StatsActor.StatsResult result;
- final ActorRef originalReplyTo;
-
- StatsActorResponse(StatsActor. StatsResult result, ActorRef originalReplyTo) {
- this.result = result;
- this.originalReplyTo = originalReplyTo;
- }
- }
-
/**
* Internal message to handle WebSocket disconnection
* @author Nattamon Paiboon
@@ -266,7 +252,6 @@ private Behavior behavior() {
.onMessage(ReadabilityResult.class, this::onReadabilityResult)
.onMessage(GetStats.class, this::onGetStats)
.onMessage(StatsResult.class, this::onStatsResult)
- .onMessage(StatsActorResponse.class, this::onStatsActorResponse)
.onMessage(InternalStop.class, this::onInternalStop)
.build();
}
@@ -386,23 +371,6 @@ private Behavior onGetStats(GetStats msg) {
return Behaviors.same();
}
- /**
- * Handles response from StatsActor and forwards to original requester.
- * @author Dara Cunningham
- */
- private Behavior onStatsActorResponse(StatsActorResponse msg) {
- context.getLog(). info("Received stats from StatsActor for query: {}", msg.result.query());
-
- // Forward to original requester (SupervisorActor)
- msg.originalReplyTo.tell(new StatsResult(
- msg.result. found(),
- msg.result.query(),
- msg.result.sortedCounts()
- ));
-
- return Behaviors.same();
- }
-
/**
* Handles StatsResult from StatsActor and forwards to original requester
* @author Dara Cunningham
From 6ce7be859b549e867f467557514942e32c9b2dd4 Mon Sep 17 00:00:00 2001
From: Dara C
Date: Sat, 29 Nov 2025 22:00:46 -0500
Subject: [PATCH 11/11] Removed old search method
---
app/controllers/HomeController.java | 40 -----------------------------
1 file changed, 40 deletions(-)
diff --git a/app/controllers/HomeController.java b/app/controllers/HomeController.java
index 93b2042..1f65237 100644
--- a/app/controllers/HomeController.java
+++ b/app/controllers/HomeController.java
@@ -133,46 +133,6 @@ public WebSocket ws() {
});
}
- /**
- * An action that performs a search with the given query and sort parameter.
- * Creates a Search Object, which fetches the results by calling the NEWS API.
- * Once done, it renders the HTML results with the search results by sending the Search object to the views
- *
- * @author Luan Tran
- * @param request the HTTP request containing session information
- * @param rawQuery the search query string
- * @param sortBy the sort parameter for the search
- * @return a CompletionStage rendering the results page with updated history and sentiment
- */
-// public CompletionStage search(Http.Request request, String rawQuery, String sortBy) {
-//
-// // Use session token or generate one
-// String token = request.session().get("user_token").orElseGet(() -> {
-// String newToken = UUID.randomUUID().toString();
-// return newToken;
-// });
-//
-// // Get cached history (might be null)
-// return asyncCache.get(token).thenCompose(optionalHistory -> {
-// List history = (ArrayList) optionalHistory.orElseGet(ArrayList::new);
-//
-// // Create search object
-// Search searchForm = new Search(rawQuery, sortBy, apiKey);
-//
-// // Fetch results asynchronously
-// return searchForm.fetchResults(this.ws)
-// .thenApply(v -> {
-// // First: Prepend new search and update cache
-// history.addFirst(searchForm);
-// // if more than 10 search, remove the oldest one
-// if(history.size() > 10) { history.removeLast(); }
-// asyncCache.set(token, history, 3600);
-// return ok(views.html.results.render(history))
-// .addingToSession(request, "user_token", token);
-// });
-// });
-// }
-
/**
* This action is performed when the source page is called. It asks to get sourceInfo from sourceInfoActor first,
* after than ask for source profile (sourceInfo + article) from sourceActor.