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.