diff --git a/app/actors/StatsActor.java b/app/actors/StatsActor.java
new file mode 100644
index 0000000..90277e5
--- /dev/null
+++ b/app/actors/StatsActor.java
@@ -0,0 +1,138 @@
+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.actor.typed.javadsl.ActorContext;
+
+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 {
+ public interface 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;
+
+ public ComputeStats(String query, List articles, ActorRef replyTo) {
+ this.query = query;
+ this.articles = articles;
+ this.replyTo = replyTo;
+ }
+ }
+
+ /**
+ * Response containing the computed word statistics.
+ */
+ public record StatsResult(
+ boolean found,
+ String query,
+ List> sortedCounts
+ ) {}
+
+ /**
+ * 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) {
+ 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);
+
+ // 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();
+ }
+}
diff --git a/app/actors/SupervisorActor.java b/app/actors/SupervisorActor.java
index 6225a35..4b8b4c9 100644
--- a/app/actors/SupervisorActor.java
+++ b/app/actors/SupervisorActor.java
@@ -9,17 +9,13 @@
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 and a shared NewsActor.
- *
- * @author Nattamon Paiboon
+ * Parent actor that manages the creation of individual UserActors for each WebSocket connection.
*/
public final class SupervisorActor {
- private SupervisorActor() {}
+ private SupervisorActor() {}
/** Base interface for all SupervisorActor messages. */
public interface Message {}
@@ -42,6 +38,46 @@ 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;
+ }
+ }
+
+ /**
+ * 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 )
*/
@@ -87,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<>();
@@ -144,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 f441493..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;
@@ -161,6 +187,9 @@ private UserActor(String id, AsyncCacheApi asyncCache, ActorContext con
Materializer mat = Materializer.matFromSystem(context.getSystem());
+ // Spawn StatsActor
+ this.statsActor = context.spawn(StatsActor.create(), "statsActor-" + id);
+
ActorRef self = context.getSelf();
Pair, Source> sinkSourcePair =
@@ -221,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();
}
@@ -309,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 50d69b4..1f65237 100644
--- a/app/controllers/HomeController.java
+++ b/app/controllers/HomeController.java
@@ -133,14 +133,14 @@ public WebSocket ws() {
});
}
- /**
- * This action is performed when the source page is called. It retrieves the cache of all sources' information and calls fetchSourcePage
- * if there is no sourcesInfo in cache, then it calls fetchSourcesInfo to get all the sources' information
- * @author Nattamon Paiboon
- * @param identifier - used as an id of the source, it could be sourceID or domains (is sourceID is null) of the source
- * @param sourceName - the official name of the source
- * @return a CompletionStage calling fetchSourcePage
- */
+ /**
+ * 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.
+ * @param identifier - used as an id of the source, it could be sourceID or domains (is sourceID is null) of the source
+ * @param sourceName - the official name of the source
+ * @return a CompletionStage, render the source page
+ * @author Nattamon Paiboon
+ */
public CompletionStage source(String identifier, String sourceName) {
// Ask SourcesInfoActor for the list of all sources
return AskPattern.ask(
@@ -186,32 +186,43 @@ public CompletionStage source(String identifier, String sourceName) {
* @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(
+ supervisorActor,
+ (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
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;
+ }
+}
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());
+ }
+
}
diff --git a/test/actors/UserActorTest.java b/test/actors/UserActorTest.java
index 8929785..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,6 +944,407 @@ 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() 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 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 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 ===
/**
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.