diff --git a/app/actors/StatsActor.java b/app/actors/StatsActor.java new file mode 100644 index 0000000..90277e5 --- /dev/null +++ b/app/actors/StatsActor.java @@ -0,0 +1,138 @@ +package actors; + +import models.Article; +import models.Stats; +import org.apache.pekko.actor.typed.ActorRef; +import org.apache.pekko.actor.typed.Behavior; +import org.apache.pekko.actor.typed.javadsl.Behaviors; +import org.apache.pekko.actor.typed.javadsl.ActorContext; + +import java.util.List; +import java.util.Map; + +/** + * Actor responsible for calculating statistics on articles. + * It receives a list of articles and computes word frequency statistics, + * then sends the results back to the requester. + * @author Dara Cunningham + */ +public class StatsActor { + public interface Message {} + + /** + * Request to compute word statistics for a list of articles + */ + public static final class ComputeStats implements Message { + final String query; + final List
articles; + final ActorRef replyTo; + + public ComputeStats(String query, List
articles, ActorRef replyTo) { + this.query = query; + this.articles = articles; + this.replyTo = replyTo; + } + } + + /** + * Response containing the computed word statistics. + */ + public record StatsResult( + boolean found, + String query, + List> sortedCounts + ) {} + + /** + * Internal message when computation completes successfully + */ + private static final class ComputeComplete implements Message { + final String query; + final List> sortedCounts; + final ActorRef replyTo; + + ComputeComplete(String query, List> sortedCounts, ActorRef replyTo) { + this.query = query; + this.sortedCounts = sortedCounts; + this.replyTo = replyTo; + } + } + + /** + * Internal message when computation fails + */ + private static final class ComputeFailed implements Message { + final String query; + final String error; + final ActorRef replyTo; + + ComputeFailed(String query, String error, ActorRef replyTo) { + this.query = query; + this.error = error; + this.replyTo = replyTo; + } + } + + // --- State --- + private final ActorContext context; + + // --- Factory --- + public static Behavior create() { + return Behaviors.setup(context -> new StatsActor(context).behavior()); + } + + // --- Constructor --- + private StatsActor(ActorContext context) { + this.context = context; + } + + // --- Behavior --- + private Behavior behavior() { + return Behaviors.receive(Message.class) + .onMessage(ComputeStats.class, this::onComputeStats) + .onMessage(ComputeComplete.class, this::onComputeComplete) + .onMessage(ComputeFailed.class, this::onComputeFailed) + .build(); + } + + private Behavior onComputeStats(ComputeStats msg) { + ActorRef self = context.getSelf(); + + try { + context.getLog().info("Computing stats for query: {} ({} articles)", + msg.query, msg.articles.size()); + + // Compute word statistics using existing Stats utility + List> sortedCounts = Stats.calculateSortedCounts(msg.articles); + + // Send completion message to self + self.tell(new ComputeComplete(msg.query, sortedCounts, msg.replyTo)); + + } catch (Exception e) { + context.getLog().error("Failed to compute stats for query: {}", msg.query, e); + self.tell(new ComputeFailed(msg.query, e.getMessage(), msg.replyTo)); + } + + return Behaviors.same(); + } + + private Behavior onComputeComplete(ComputeComplete msg) { + context.getLog().info("Stats computed successfully for query: {} - {} unique words", + msg.query, msg.sortedCounts.size()); + + // Reply to the requesting UserActor with the results + msg.replyTo.tell(new UserActor.StatsResult(true, msg.query, msg.sortedCounts)); + + return Behaviors.same(); + } + + private Behavior onComputeFailed(ComputeFailed msg) { + context.getLog().error("Stats computation failed for query: {}, error: {}", + msg.query, msg.error); + + // Reply to the requesting UserActor with failure + msg.replyTo.tell(new UserActor.StatsResult(false, msg.query, List.of())); + + return Behaviors.same(); + } +} diff --git a/app/actors/SupervisorActor.java b/app/actors/SupervisorActor.java index 6225a35..4b8b4c9 100644 --- a/app/actors/SupervisorActor.java +++ b/app/actors/SupervisorActor.java @@ -9,17 +9,13 @@ import play.cache.AsyncCacheApi; import play.libs.ws.WSClient; -import java.util.HashMap; -import java.util.Map; +import java.util.*; /** - * Parent actor that manages the creation of individual UserActors - * for each WebSocket connection and a shared NewsActor. - * - * @author Nattamon Paiboon + * Parent actor that manages the creation of individual UserActors for each WebSocket connection. */ public final class SupervisorActor { - private SupervisorActor() {} + private SupervisorActor() {} /** Base interface for all SupervisorActor messages. */ public interface Message {} @@ -42,6 +38,46 @@ public Create(String id, ActorRef> replyTo) { } } + /** + * Message to request stats for a user's query. + * @author Dara Cunningham + */ + public static final class GetStats implements Message { + public final String userId; + public final String query; + public final ActorRef replyTo; + + public GetStats(String userId, String query, ActorRef replyTo) { + this.userId = userId; + this.query = query; + this.replyTo = replyTo; + } + } + + /** + * Internal message to receive stats from UserActor. + * @author Dara Cunningham + */ + private static final class UserStatsResponse implements Message { + final UserActor.StatsResult result; + final ActorRef originalReplyTo; + + UserStatsResponse(UserActor.StatsResult result, ActorRef originalReplyTo) { + this.result = result; + this.originalReplyTo = originalReplyTo; + } + } + + /** + * Response message containing stats results (sent back to HomeController). + * @author Dara Cunningham + */ + public record StatsResponse( + boolean found, + String query, + List> sortedCounts + ) {} + /** * For get other additional actors which will be used in controller ( sourceActor and sourceInfoActor ) */ @@ -87,34 +123,34 @@ private static final class UserActorStopped implements Message { * @author Nattamon Paiboon, Luan Tran * */ public static Behavior create(WSClient wsClient, String apiKey, AsyncCacheApi asyncCache) { - return Behaviors.setup(context -> { - // create source actor - ActorRef sourceActor = context.spawn( - SourceActor.create(wsClient), - "sourceActor" - ); - // create sourceInfo actor - ActorRef sourcesInfoActor = context.spawn( - SourcesInfoActor.create(wsClient, apiKey), - "sourcesInfoActor" - ); - context.getLog().info("sourceActor and sourcesInfoActor spawned and ready"); + return Behaviors.setup(context -> { + // create source actor + ActorRef sourceActor = context.spawn( + SourceActor.create(wsClient), + "sourceActor" + ); + // create sourceInfo actor + ActorRef sourcesInfoActor = context.spawn( + SourcesInfoActor.create(wsClient, apiKey), + "sourcesInfoActor" + ); + context.getLog().info("sourceActor and sourcesInfoActor spawned and ready"); - // Spawn a single NewsActor that will be shared by all UserActors - ActorRef newsActor = context.spawn( - NewsActor.create(wsClient, apiKey), "newsActor" - ); - context.getLog().info("NewsActor spawned and ready"); + // Spawn a single NewsActor that will be shared by all UserActors + ActorRef newsActor = context.spawn( + NewsActor.create(wsClient, apiKey), "newsActor" + ); + context.getLog().info("NewsActor spawned and ready"); - // Spawn a single ReadabilityActor that will be shared by all UserActors - ActorRef readabilityActor = context.spawn( - ReadabilityActor.create(), "readabilityActor" - ); + // Spawn a single ReadabilityActor that will be shared by all UserActors + ActorRef readabilityActor = context.spawn( + ReadabilityActor.create(), "readabilityActor" + ); - context.getLog().info("ReadabilityActor spawned and ready"); + context.getLog().info("ReadabilityActor spawned and ready"); - // Registry to track active UserActors by session ID - Map> userActors = new HashMap<>(); + // Registry to track active UserActors by session ID + Map> userActors = new HashMap<>(); @@ -144,16 +180,50 @@ public static Behavior create(WSClient wsClient, String apiKey, AsyncCa // Watch the actor to know when it stops (only happens after timeout or explicit stop) context.watchWith(child, new UserActorStopped(create.id)); - // Ask the child to establish the stream - child.tell(new UserActor.Connect(create.replyTo)); - } - + // Ask the child to establish the stream + child.tell(new UserActor.Connect(create.replyTo)); + } return Behaviors.same(); - }) - .onMessage(GetOtherActors.class, msg -> { + }) + .onMessage(GetStats.class, msg -> { + context.getLog().info("Received GetStats request for session: {}, query: {}", + msg. userId, msg.query); + + ActorRef userActor = userActors.get(msg.userId); + + if (userActor == null) { + context. getLog().info("No UserActor found for session: {}", msg.userId); + msg.replyTo.tell(new StatsResponse(false, msg.query, List.of())); + } else { + ActorRef adapter = context. messageAdapter( + UserActor. StatsResult.class, + result -> new UserStatsResponse(result, msg.replyTo) + ); + userActor.tell(new UserActor.GetStats(msg.query, adapter)); + } + + return Behaviors.same(); + }) + .onMessage(UserStatsResponse.class, msg -> { + context.getLog().info("Received stats response for query: {}", msg. result.query()); + + msg.originalReplyTo.tell(new StatsResponse( + msg.result.found(), + msg.result.query(), + msg.result.sortedCounts() + )); + + return Behaviors.same(); + }) + .onMessage(GetOtherActors.class, msg -> { msg.replyTo.tell(new OtherActors(sourceActor, sourcesInfoActor)); return Behaviors.same(); }) + .onMessage(UserActorStopped.class, stopped -> { + context.getLog(). info("UserActor terminated for session: {}, removing from registry", stopped. id); + userActors.remove(stopped.id); + return Behaviors.same(); + }) .build(); }); } diff --git a/app/actors/UserActor.java b/app/actors/UserActor.java index f441493..f42b1d6 100644 --- a/app/actors/UserActor.java +++ b/app/actors/UserActor.java @@ -3,9 +3,9 @@ import org.apache.pekko.Done; import org.apache.pekko.NotUsed; import org.apache.pekko.actor.typed.ActorRef; -import org.apache.pekko.actor.typed.Behavior; +import org.apache.pekko.actor.typed. Behavior; import org.apache.pekko.actor.typed.javadsl.ActorContext; -import org.apache.pekko.actor.typed.javadsl.Behaviors; +import org.apache.pekko.actor.typed.javadsl. Behaviors; import org.apache.pekko.stream.Materializer; import org.apache.pekko.stream.javadsl.*; import org.apache.pekko.japi.Pair; @@ -13,6 +13,9 @@ import play.cache.AsyncCacheApi; import play.libs.Json; import models.Search; +import models.Article; +import models.Search; +import models.Stats; import java.time.Duration; import java.util.*; @@ -85,6 +88,28 @@ public ReadabilityResult(List searches) { } } + /** + * Request to compute stats for a query (from SupervisorActor) + * @author Dara Cunningham + */ + public static final class GetStats implements Message { + public final String query; + public final ActorRef replyTo; + public GetStats(String query, ActorRef replyTo) { + this.query = query; + this.replyTo = replyTo; + } + } + /** + * Response containing stats computation result. + * Implements Message so StatsActor can send it directly to UserActor. + * @author Dara Cunningham + */ + public record StatsResult( + boolean found, + String query, + List> sortedCounts + ) implements Message {} /** * Internal message to handle WebSocket disconnection @@ -98,11 +123,12 @@ private static final class InternalStop implements Message {} // private final String apiKey; private final ActorRef newsActor; private final ActorRef rActor; + private final ActorRef statsActor; private final ActorContext context; private List searchHistory = new ArrayList<>(); private final Set watchedQueries = new HashSet<>(); private final Map searchInstances = new HashMap<>(); - + private final Map> pendingStatsRequests = new HashMap<>(); private final Sink hubSink; private final Flow websocketFlow; @@ -161,6 +187,9 @@ private UserActor(String id, AsyncCacheApi asyncCache, ActorContext con Materializer mat = Materializer.matFromSystem(context.getSystem()); + // Spawn StatsActor + this.statsActor = context.spawn(StatsActor.create(), "statsActor-" + id); + ActorRef self = context.getSelf(); Pair, Source> sinkSourcePair = @@ -221,6 +250,8 @@ private Behavior behavior() { .onMessage(NewsResults.class, this::onNewsResults) .onMessage(NewsFetchFailed.class, this::onNewsFetchFailed) .onMessage(ReadabilityResult.class, this::onReadabilityResult) + .onMessage(GetStats.class, this::onGetStats) + .onMessage(StatsResult.class, this::onStatsResult) .onMessage(InternalStop.class, this::onInternalStop) .build(); } @@ -309,6 +340,55 @@ private Behavior onNewsFetchFailed(UserActor.NewsFetchFailed return Behaviors.same(); } + /** + * Handles GetStats request by delegating to StatsActor + * @author Dara Cunningham + */ + private Behavior onGetStats(GetStats msg) { + context.getLog().info("User {} requesting stats for: {}", id, msg.query); + + Optional searchOpt = searchHistory.stream() + .filter(s -> s.getRawQuery().equals(msg.query)) + .findFirst(); + + if (searchOpt.isEmpty()) { + context.getLog().info("Search not found for query: {}", msg.query); + msg.replyTo.tell(new StatsResult(false, msg.query, List.of())); + } + else { + Search search = searchOpt.get(); + List
articles = search.getResults(); + + context.getLog().info("Delegating stats computation for {} articles to StatsActor", articles.size()); + + // Store the pending request so we can forward the response + pendingStatsRequests.put(msg.query, msg.replyTo); + + // Delegate to StatsActor - it will reply directly with StatsResult + statsActor.tell(new StatsActor.ComputeStats(msg.query, articles, context.getSelf())); + } + + return Behaviors.same(); + } + + /** + * Handles StatsResult from StatsActor and forwards to original requester + * @author Dara Cunningham + */ + private Behavior onStatsResult(StatsResult msg) { + context. getLog().info("Received stats result for query: {} (found: {})", msg.query(), msg. found()); + + ActorRef originalReplyTo = pendingStatsRequests.remove(msg.query()); + + if (originalReplyTo != null) { + originalReplyTo.tell(msg); + } else { + context.getLog(). warn("No pending stats request found for query: {}", msg.query()); + } + + return Behaviors. same(); + } + /** * Handles WebSocket disconnection while preserving actor state for reconnection * @param msg the internal stop message diff --git a/app/assets/javascripts/search.coffee b/app/assets/javascripts/search.coffee index c9d15d9..bb47470 100644 --- a/app/assets/javascripts/search.coffee +++ b/app/assets/javascripts/search.coffee @@ -4,7 +4,18 @@ formatNumber = (val) -> num = Number(val) if isNaN(num) then "N/A" else num.toFixed(2) +# Get session ID from localStorage +getSessionId = -> + sessionId = localStorage.getItem('newsapp_session_id') + if ! sessionId + console.warn "No session ID found in localStorage" + return "" + return sessionId + window.App.renderSearch = (search) -> + # Get session ID for stats link + sessionId = getSessionId() + # Sentiment part sentimentHtml = "" if search.sentiment == ":-)" @@ -42,7 +53,7 @@ window.App.renderSearch = (search) ->

- + Statistics for "#{search.rawQuery}"

diff --git a/app/controllers/HomeController.java b/app/controllers/HomeController.java index 50d69b4..1f65237 100644 --- a/app/controllers/HomeController.java +++ b/app/controllers/HomeController.java @@ -133,14 +133,14 @@ public WebSocket ws() { }); } - /** - * This action is performed when the source page is called. It retrieves the cache of all sources' information and calls fetchSourcePage - * if there is no sourcesInfo in cache, then it calls fetchSourcesInfo to get all the sources' information - * @author Nattamon Paiboon - * @param identifier - used as an id of the source, it could be sourceID or domains (is sourceID is null) of the source - * @param sourceName - the official name of the source - * @return a CompletionStage calling fetchSourcePage - */ + /** + * This action is performed when the source page is called. It asks to get sourceInfo from sourceInfoActor first, + * after than ask for source profile (sourceInfo + article) from sourceActor. + * @param identifier - used as an id of the source, it could be sourceID or domains (is sourceID is null) of the source + * @param sourceName - the official name of the source + * @return a CompletionStage, render the source page + * @author Nattamon Paiboon + */ public CompletionStage source(String identifier, String sourceName) { // Ask SourcesInfoActor for the list of all sources return AskPattern.ask( @@ -186,32 +186,43 @@ public CompletionStage source(String identifier, String sourceName) { * @param rawQuery the search query string * @return stats page specific to the query */ - public CompletionStage stats(Http.Request request, String rawQuery) { - // Use session token or generate one - String token = request.session().get("user_token").orElseGet(() -> { - return UUID.randomUUID().toString(); - }); - - // Get search history from cache - return asyncCache.get(token).thenApply(optionalHistory -> { - List history = (ArrayList) optionalHistory.orElseGet(ArrayList::new); - - // Find the specific search in history - Optional searchOpt = history.stream() - .filter(s -> s.getRawQuery().equals(rawQuery)) - .findFirst(); - - if (searchOpt.isEmpty()) { - return notFound("Search not found") - .addingToSession(request, "user_token", token); - } - - Search search = searchOpt.get(); - StatsInfo statsInfo = new StatsInfo(rawQuery, search.getResults()); - - return ok(views.html.stats.render(rawQuery, (java.util.List) statsInfo.getSortedCounts())) - .addingToSession(request, "user_token", token); - }); - } + public CompletionStage stats(Http.Request request, String rawQuery) { + // Try to get session ID from query parameter first (for stats links), + // then fall back to session cookie + String token = request.queryString("sessionId") + .orElseGet(() -> request.session().get("user_token").orElseGet(() -> { + String newId = UUID.randomUUID().toString(); + System.out.println("WARNING: No sessionId from query or session, generated: " + newId); + return newId; + })); + + System.out.println(">>> Stats request - Session ID: " + token + ", Query: " + rawQuery); + + // Ask SupervisorActor for stats + CompletionStage statsFuture = AskPattern.ask( + supervisorActor, + (ActorRef replyTo) -> + new SupervisorActor.GetStats(token, rawQuery, replyTo), + Duration.ofSeconds(10), + actorSystem.scheduler() + ); + + return statsFuture.thenApply(response -> { + if (response.found()) { + @SuppressWarnings("unchecked") + List> wildcardList = (List>) (List) response.sortedCounts(); + + return ok(views.html.stats.render(rawQuery, wildcardList)) + .addingToSession(request, "user_token", token); + } + else { + return notFound("Search not found") + .addingToSession(request, "user_token", token); + } + }).exceptionally(e -> { + e.printStackTrace(); + return internalServerError("Error computing stats"); + }); + } } diff --git a/app/views/results.scala.html b/app/views/results.scala.html index 2aa66d3..4b5053c 100644 --- a/app/views/results.scala.html +++ b/app/views/results.scala.html @@ -1,5 +1,5 @@ @import scala.jdk.CollectionConverters._ -@(history: java.util.List[Search]) +@(history: java.util.List[Search], sessionToken: String) @main("NotiLytics Search Results") { @search() @@ -33,7 +33,7 @@

Search Results

- + Statistics for "@searchForm.getRawQuery"

diff --git a/app/views/stats.scala.html b/app/views/stats.scala.html index 04a83a9..74a027f 100644 --- a/app/views/stats.scala.html +++ b/app/views/stats.scala.html @@ -1,7 +1,7 @@ @import scala.jdk.CollectionConverters._ @import java.util.Map.Entry -@(rawQuery: String, sortedCounts: java.util.List[Entry[String, Int]]) +@(rawQuery: String, sortedCounts: java.util.List[Entry[_, _]]) @main("NotiLytics - Statistics for '" + rawQuery + "'") {

Word Statistics

diff --git a/test/actors/StatsActorTest.java b/test/actors/StatsActorTest.java new file mode 100644 index 0000000..249692c --- /dev/null +++ b/test/actors/StatsActorTest.java @@ -0,0 +1,442 @@ +package actors; + +import models.Article; +import models.Stats; +import org.apache.pekko.actor.testkit.typed.javadsl.ActorTestKit; +import org.apache.pekko.actor.testkit.typed.javadsl.TestProbe; +import org.apache.pekko.actor.typed.ActorRef; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; +import org.mockito.MockedStatic; + +/** + * Unit tests for StatsActor using Pekko TestKit. + * Tests cover all message handlers for 100% code coverage. + * @author Dara Cunningham + */ +public class StatsActorTest { + + private static ActorTestKit testKit; + + @BeforeClass + public static void setupClass() { + testKit = ActorTestKit.create(); + } + + @AfterClass + public static void teardownClass() { + testKit.shutdownTestKit(); + } + + /** + * Test that StatsActor can be created successfully. + * @author Dara Cunningham + */ + @Test + public void testStatsActorCreation() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + assertNotNull("StatsActor should be created", statsActor); + } + + /** + * Test successful computation of stats with valid articles. + * Verifies the actor sends a StatsResult with found=true and sorted word counts. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsSuccess() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + // Create mock articles with descriptions + List
articles = createMockArticles(5, "test description with words"); + + // Send ComputeStats message + statsActor.tell(new StatsActor.ComputeStats("testQuery", articles, userActorProbe.ref())); + + // Expect StatsResult + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertTrue("Result should indicate found", result.found()); + assertEquals("Query should match", "testQuery", result.query()); + assertNotNull("Sorted counts should not be null", result.sortedCounts()); + } + + /** + * Test computation with empty article list. + * Should return found=true with empty sorted counts. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsEmptyArticles() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + List
articles = new ArrayList<>(); + + statsActor.tell(new StatsActor.ComputeStats("emptyQuery", articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertTrue("Result should indicate found", result.found()); + assertEquals("Query should match", "emptyQuery", result.query()); + assertTrue("Sorted counts should be empty", result.sortedCounts().isEmpty()); + } + + /** + * Test computation with articles containing various word frequencies. + * Verifies the word counts are sorted correctly. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsWordFrequency() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + // Create articles with repeated words to test frequency counting + List
articles = new ArrayList<>(); + Article article1 = mock(Article.class); + when(article1.getDescription()).thenReturn("hello world hello"); + when(article1.getTitle()).thenReturn("Test Article"); + articles.add(article1); + + Article article2 = mock(Article.class); + when(article2.getDescription()).thenReturn("hello world test"); + when(article2.getTitle()).thenReturn("Test Article 2"); + articles.add(article2); + + statsActor.tell(new StatsActor.ComputeStats("frequencyQuery", articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertTrue("Result should indicate found", result.found()); + assertFalse("Sorted counts should not be empty", result.sortedCounts().isEmpty()); + + // Verify "hello" appears most frequently (3 times) + if (!result.sortedCounts().isEmpty()) { + Map.Entry topWord = result.sortedCounts().get(0); + assertEquals("Top word should be 'hello'", "hello", topWord.getKey()); + assertEquals("Count should be 3", Integer.valueOf(3), topWord.getValue()); + } + } + + /** + * Test computation with a single article. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsSingleArticle() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + List
articles = createMockArticles(1, "single article description here"); + + statsActor.tell(new StatsActor.ComputeStats("singleQuery", articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertTrue("Result should indicate found", result.found()); + assertEquals("Query should match", "singleQuery", result.query()); + assertFalse("Sorted counts should not be empty", result.sortedCounts().isEmpty()); + } + + /** + * Test computation with many articles to verify scalability. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsManyArticles() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + List
articles = createMockArticles(50, "comprehensive test description with multiple words"); + + statsActor.tell(new StatsActor.ComputeStats("manyArticlesQuery", articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(5) + ); + + assertTrue("Result should indicate found", result.found()); + assertEquals("Query should match", "manyArticlesQuery", result.query()); + } + + /** + * Test that multiple ComputeStats requests are handled independently. + * @author Dara Cunningham + */ + @Test + public void testMultipleConcurrentComputeStats() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe probe1 = testKit.createTestProbe(); + TestProbe probe2 = testKit.createTestProbe(); + TestProbe probe3 = testKit.createTestProbe(); + + List
articles1 = createMockArticles(3, "first query articles"); + List
articles2 = createMockArticles(4, "second query articles"); + List
articles3 = createMockArticles(5, "third query articles"); + + statsActor.tell(new StatsActor.ComputeStats("query1", articles1, probe1.ref())); + statsActor.tell(new StatsActor.ComputeStats("query2", articles2, probe2.ref())); + statsActor.tell(new StatsActor.ComputeStats("query3", articles3, probe3.ref())); + + UserActor.StatsResult result1 = probe1.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3)); + UserActor.StatsResult result2 = probe2.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3)); + UserActor.StatsResult result3 = probe3.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3)); + + assertEquals("First result query should match", "query1", result1.query()); + assertEquals("Second result query should match", "query2", result2.query()); + assertEquals("Third result query should match", "query3", result3.query()); + + assertTrue("All results should be found", result1.found() && result2.found() && result3.found()); + } + + /** + * Test computation with articles having null descriptions. + * Should handle gracefully without throwing exception. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsNullDescription() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + List
articles = new ArrayList<>(); + Article article = mock(Article.class); + when(article.getDescription()).thenReturn(null); + when(article.getTitle()).thenReturn("Title Only"); + articles.add(article); + + statsActor.tell(new StatsActor.ComputeStats("nullDescQuery", articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + // Should complete without error, either found or not depending on implementation + assertNotNull("Result should not be null", result); + assertEquals("Query should match", "nullDescQuery", result.query()); + } + + /** + * Test computation with articles having empty descriptions. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsEmptyDescription() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + List
articles = new ArrayList<>(); + Article article = mock(Article.class); + when(article.getDescription()).thenReturn(""); + when(article.getTitle()).thenReturn("Empty Description Article"); + articles.add(article); + + statsActor.tell(new StatsActor.ComputeStats("emptyDescQuery", articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertTrue("Result should indicate found", result.found()); + assertEquals("Query should match", "emptyDescQuery", result.query()); + } + + /** + * Test computation with special characters in article descriptions. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsSpecialCharacters() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + List
articles = new ArrayList<>(); + Article article = mock(Article.class); + when(article.getDescription()).thenReturn("hello! world? test@#$% special-chars"); + when(article.getTitle()).thenReturn("Special Chars Article"); + articles.add(article); + + statsActor.tell(new StatsActor.ComputeStats("specialQuery", articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertTrue("Result should indicate found", result.found()); + assertNotNull("Sorted counts should not be null", result.sortedCounts()); + } + + /** + * Test that StatsResult record is properly constructed. + * @author Dara Cunningham + */ + @Test + public void testStatsResultRecord() { + List> sortedCounts = List.of( + Map.entry("word1", 5), + Map.entry("word2", 3) + ); + + StatsActor.StatsResult result = new StatsActor.StatsResult(true, "testQuery", sortedCounts); + + assertTrue("Found should be true", result.found()); + assertEquals("Query should match", "testQuery", result.query()); + assertEquals("Sorted counts size should match", 2, result.sortedCounts().size()); + } + + /** + * Test ComputeStats message construction and field access. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsMessageConstruction() { + TestProbe probe = testKit.createTestProbe(); + List
articles = createMockArticles(2, "test"); + + StatsActor.ComputeStats msg = new StatsActor.ComputeStats("queryTest", articles, probe.ref()); + + assertEquals("Query should match", "queryTest", msg.query); + assertEquals("Articles should match", articles, msg.articles); + assertEquals("ReplyTo should match", probe.ref(), msg.replyTo); + } + + /** + * Test that actor continues processing after handling messages. + * Verifies Behaviors.same() is returned. + * @author Dara Cunningham + */ + @Test + public void testActorContinuesAfterProcessing() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe probe1 = testKit.createTestProbe(); + TestProbe probe2 = testKit.createTestProbe(); + + // Send first message + statsActor.tell(new StatsActor.ComputeStats("first", createMockArticles(2, "first"), probe1.ref())); + probe1.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3)); + + // Actor should still be alive and process second message + statsActor.tell(new StatsActor.ComputeStats("second", createMockArticles(2, "second"), probe2.ref())); + UserActor.StatsResult result = probe2.expectMessageClass(UserActor.StatsResult.class, Duration.ofSeconds(3)); + + assertEquals("Second query should be processed", "second", result.query()); + } + + /** + * Test computation with articles containing mixed case words. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsMixedCaseWords() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + List
articles = new ArrayList<>(); + Article article = mock(Article.class); + when(article.getDescription()).thenReturn("Hello HELLO hello HeLLo"); + when(article.getTitle()).thenReturn("Mixed Case"); + articles.add(article); + + statsActor.tell(new StatsActor.ComputeStats("mixedCaseQuery", articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertTrue("Result should indicate found", result.found()); + assertFalse("Sorted counts should not be empty", result.sortedCounts().isEmpty()); + } + + /** + * Test that query with spaces is handled correctly. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsQueryWithSpaces() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + List
articles = createMockArticles(3, "test description"); + String queryWithSpaces = "climate change news"; + + statsActor.tell(new StatsActor.ComputeStats(queryWithSpaces, articles, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertTrue("Result should indicate found", result.found()); + assertEquals("Query should match including spaces", queryWithSpaces, result.query()); + } + + /** + * Test ComputeFailed path when Stats.calculateSortedCounts throws an exception. + * Verifies onComputeFailed is called and sends StatsResult with found=false. + * @author Dara Cunningham + */ + @Test + public void testComputeStatsFailure() { + ActorRef statsActor = testKit.spawn(StatsActor.create()); + TestProbe userActorProbe = testKit.createTestProbe(); + + // Pass null to trigger NullPointerException inside the actor + statsActor.tell(new StatsActor.ComputeStats("failQuery", null, userActorProbe.ref())); + + UserActor.StatsResult result = userActorProbe.expectMessageClass( + UserActor.StatsResult.class, + Duration.ofSeconds(3) + ); + + assertFalse("Result should indicate not found", result.found()); + } + + // === Helper Methods === + + /** + * Create a list of mock Article objects with the given description. + * @param count number of articles to create + * @param description description text for each article + * @return list of mock articles + */ + private List
createMockArticles(int count, String description) { + List
articles = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Article article = mock(Article.class); + when(article.getTitle()).thenReturn("Article " + i); + when(article.getDescription()).thenReturn(description + " " + i); + when(article.getUrl()).thenReturn("https://example.com/article-" + i); + when(article.getSourceName()).thenReturn("Test Source"); + articles.add(article); + } + return articles; + } +} diff --git a/test/actors/SupervisorActorTest.java b/test/actors/SupervisorActorTest.java index 4c48450..1038d86 100644 --- a/test/actors/SupervisorActorTest.java +++ b/test/actors/SupervisorActorTest.java @@ -259,4 +259,105 @@ public void testSameSessionDifferentProbes() { Flow flow2 = probe2.receiveMessage(Duration.ofSeconds(3)); assertNotNull("Second probe should receive flow", flow2); } + + /** + * SupervisorActor handles GetStats request and returns stats results + * @author Dara Cunningham + */ + @Test + public void testGetStatsWithExistingUserActor() { + String sessionId = "stats-session-" + System.currentTimeMillis(); + + // First, create a UserActor for this session + TestProbe> flowProbe = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.Create(sessionId, flowProbe.ref())); + Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3)); + assertNotNull("UserActor should be created", flow); + + // Now request stats for this session + TestProbe statsProbe = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.GetStats(sessionId, "test-query", statsProbe.ref())); + + SupervisorActor.StatsResponse response = statsProbe.receiveMessage(Duration.ofSeconds(3)); + assertNotNull("Should receive stats response", response); + assertEquals("Query should match", "test-query", response.query()); + // UserActor may return empty stats if no searches performed, but should respond + assertNotNull("Stats list should not be null", response.sortedCounts()); + } + + /** + * SupervisorActor handles GetStats request for non-existent session + * @author Dara Cunningham + */ + @Test + public void testGetStatsWithNonExistentUserActor() { + String nonExistentSessionId = "nonexistent-session-" + System.currentTimeMillis(); + + TestProbe statsProbe = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.GetStats(nonExistentSessionId, "test-query", statsProbe.ref())); + + SupervisorActor.StatsResponse response = statsProbe.receiveMessage(Duration.ofSeconds(3)); + assertNotNull("Should receive stats response", response); + assertFalse("Stats should not be found", response.found()); + assertEquals("Query should match", "test-query", response.query()); + assertTrue("Stats list should be empty", response.sortedCounts().isEmpty()); + } + + /** + * SupervisorActor handles multiple stats requests for same session + * @author Dara Cunningham + */ + @Test + public void testMultipleStatsRequests() { + String sessionId = "multi-stats-session-" + System.currentTimeMillis(); + + // Create UserActor + TestProbe> flowProbe = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.Create(sessionId, flowProbe.ref())); + flowProbe.receiveMessage(Duration.ofSeconds(3)); + + // Request stats multiple times + TestProbe statsProbe1 = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.GetStats(sessionId, "query1", statsProbe1.ref())); + SupervisorActor.StatsResponse response1 = statsProbe1.receiveMessage(Duration.ofSeconds(3)); + assertNotNull("First stats response should be received", response1); + assertEquals("First query should match", "query1", response1.query()); + + TestProbe statsProbe2 = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.GetStats(sessionId, "query2", statsProbe2.ref())); + SupervisorActor.StatsResponse response2 = statsProbe2.receiveMessage(Duration.ofSeconds(3)); + assertNotNull("Second stats response should be received", response2); + assertEquals("Second query should match", "query2", response2.query()); + } + + /** + * SupervisorActor handles stats requests for different sessions independently + * @author Dara Cunningham + */ + @Test + public void testStatsRequestsForDifferentSessions() { + String session1 = "stats-session-1-" + System.currentTimeMillis(); + String session2 = "stats-session-2-" + System.currentTimeMillis(); + + // Create UserActors for both sessions + TestProbe> flowProbe1 = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.Create(session1, flowProbe1.ref())); + flowProbe1.receiveMessage(Duration.ofSeconds(3)); + + TestProbe> flowProbe2 = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.Create(session2, flowProbe2.ref())); + flowProbe2.receiveMessage(Duration.ofSeconds(3)); + + // Request stats for both sessions + TestProbe statsProbe1 = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.GetStats(session1, "query-for-session-1", statsProbe1.ref())); + SupervisorActor.StatsResponse response1 = statsProbe1.receiveMessage(Duration.ofSeconds(3)); + assertEquals("Session 1 query should match", "query-for-session-1", response1.query()); + + TestProbe statsProbe2 = testKit.createTestProbe(); + supervisorActor.tell(new SupervisorActor.GetStats(session2, "query-for-session-2", statsProbe2.ref())); + SupervisorActor.StatsResponse response2 = statsProbe2.receiveMessage(Duration.ofSeconds(3)); + assertEquals("Session 2 query should match", "query-for-session-2", response2.query()); + } + } diff --git a/test/actors/UserActorTest.java b/test/actors/UserActorTest.java index 8929785..a8daaec 100644 --- a/test/actors/UserActorTest.java +++ b/test/actors/UserActorTest.java @@ -24,6 +24,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import static org.junit.Assert.*; @@ -943,6 +944,407 @@ public void testOnNewsResultsDifferentSortBySameQuery() throws Exception { assertEquals("Should have 2 separate searches (different sortBy)", 2, history2.size()); } + /** + * UserActor handles GetStats request for existing search + * Tests the else branch of onGetStats where search is found + * @author Dara Cunningham + */ + @Test + public void testGetStatsWithExistingSearch() throws Exception { + TestProbe> flowProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.Connect(flowProbe.ref())); + Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3)); + + // Setup TestSource and TestSink + Pair, TestSubscriber.Probe> probes = + TestSource.create(testKit.system()) + .via(flow) + .toMat(TestSink.create(testKit.system()), Keep.both()) + .run(testKit.system()); + + TestPublisher.Probe pub = probes.first(); + TestSubscriber.Probe sub = probes.second(); + + sub.request(10); + + // Send a query to create a search instance + JsonNode queryJson = Json.newObject() + .put("query", "test-query") + .put("sortBy", "publishedAt"); + pub.sendNext(queryJson); + + // Wait for WatchQuery + newsActorProbe.receiveMessage(Duration.ofSeconds(3)); + + // Send search result to add to history + Search search = createMockSearch("test-query", "publishedAt", 5, 0); + userActor.tell(new UserActor.NewsResults(search)); + simulateReadabilityResponse(); + + // Skip to get history update + for (int i = 0; i < 5; i++) { + JsonNode msg = sub.expectNext(); + if (msg.isArray()) break; + } + + // Now request stats for this query + TestProbe statsProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.GetStats("test-query", statsProbe.ref())); + + // Should receive stats result from StatsActor delegation + UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5)); + assertNotNull("Should receive stats result", result); + assertTrue("Stats should be found", result.found()); + assertEquals("Query should match", "test-query", result.query()); + } + + /** + * UserActor handles GetStats request for non-existent search + * Tests the if branch of onGetStats where search is not found + * @author Dara Cunningham + */ + @Test + public void testGetStatsWithNonExistentSearch() { + TestProbe> flowProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.Connect(flowProbe.ref())); + flowProbe.receiveMessage(Duration.ofSeconds(3)); + + // Request stats for a query that doesn't exist in history + TestProbe statsProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.GetStats("nonexistent-query", statsProbe.ref())); + + // Should receive immediate response with found=false + UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(3)); + assertNotNull("Should receive stats result", result); + assertFalse("Stats should not be found", result.found()); + assertEquals("Query should match", "nonexistent-query", result.query()); + assertTrue("Stats list should be empty", result.sortedCounts().isEmpty()); + } + + /** + * UserActor handles StatsResult from StatsActor and forwards to original requester + * Tests onStatsResult when there is a pending request + * @author Dara Cunningham + */ + @Test + public void testOnStatsResultHandling() throws Exception { + TestProbe> flowProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.Connect(flowProbe.ref())); + Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3)); + + // Setup TestSource and TestSink + Pair, TestSubscriber.Probe> probes = + TestSource.create(testKit.system()) + .via(flow) + .toMat(TestSink.create(testKit.system()), Keep.both()) + .run(testKit.system()); + + TestPublisher.Probe pub = probes.first(); + TestSubscriber.Probe sub = probes.second(); + + sub.request(10); + + // Send a query to create a search instance + JsonNode queryJson = Json.newObject() + .put("query", "stats-query") + .put("sortBy", "publishedAt"); + pub.sendNext(queryJson); + + // Wait for WatchQuery + newsActorProbe.receiveMessage(Duration.ofSeconds(3)); + + // Add a search to history + Search search = createMockSearch("stats-query", "publishedAt", 5, 0); + userActor.tell(new UserActor.NewsResults(search)); + simulateReadabilityResponse(); + + // Skip to get history update + for (int i = 0; i < 5; i++) { + JsonNode msg = sub.expectNext(); + if (msg.isArray()) break; + } + + // Request stats - this creates a pending request and delegates to StatsActor + TestProbe statsProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.GetStats("stats-query", statsProbe.ref())); + + // StatsActor will compute and send back StatsResult + // UserActor's onStatsResult will forward to the original requester + UserActor.StatsResult response = statsProbe.receiveMessage(Duration.ofSeconds(5)); + assertNotNull("Should receive forwarded stats result", response); + assertTrue("Stats should be found", response.found()); + assertEquals("Query should match", "stats-query", response.query()); + } + + /** + * UserActor handles StatsResult without pending request + * Tests onStatsResult when no one is waiting for the response (logs warning) + * @author Dara Cunningham + */ + @Test + public void testOnStatsResultWithoutPendingRequest() { + TestProbe> flowProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.Connect(flowProbe.ref())); + flowProbe.receiveMessage(Duration.ofSeconds(3)); + + // Send StatsResult directly without first requesting stats + // This simulates an orphan response (e.g., late arrival after timeout) + List> counts = new ArrayList<>(); + counts.add(new java.util.AbstractMap.SimpleEntry<>("orphan", 1)); + + UserActor.StatsResult statsResult = new UserActor.StatsResult( + true, "orphan-query", counts + ); + + // This should be handled gracefully (logged warning but no crash) + userActor.tell(statsResult); + + // Verify actor is still functional by sending another message + TestProbe statsProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.GetStats("another-query", statsProbe.ref())); + + // Should receive response for non-existent query + UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(3)); + assertNotNull("Actor should still be functional", result); + assertFalse("Query should not be found", result.found()); + } + + /** + * UserActor handles multiple GetStats requests for different queries + * Tests that pending stats requests are tracked correctly + * @author Dara Cunningham + */ + @Test + public void testMultipleGetStatsRequests() throws Exception { + TestProbe> flowProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.Connect(flowProbe.ref())); + Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3)); + + // Setup TestSource and TestSink + Pair, TestSubscriber.Probe> probes = + TestSource.create(testKit.system()) + .via(flow) + .toMat(TestSink.create(testKit.system()), Keep.both()) + .run(testKit.system()); + + TestPublisher.Probe pub = probes.first(); + TestSubscriber.Probe sub = probes.second(); + + sub.request(15); + + // Send two different queries + JsonNode query1 = Json.newObject() + .put("query", "query1") + .put("sortBy", "publishedAt"); + JsonNode query2 = Json.newObject() + .put("query", "query2") + .put("sortBy", "publishedAt"); + + pub.sendNext(query1); + pub.sendNext(query2); + + // Wait for WatchQuery messages + newsActorProbe.receiveMessage(Duration.ofSeconds(3)); + newsActorProbe.receiveMessage(Duration.ofSeconds(3)); + + // Add two different searches + Search search1 = createMockSearch("query1", "publishedAt", 3, 0); + userActor.tell(new UserActor.NewsResults(search1)); + simulateReadabilityResponse(); + + Search search2 = createMockSearch("query2", "publishedAt", 4, 0); + userActor.tell(new UserActor.NewsResults(search2)); + simulateReadabilityResponse(); + + // Skip history updates + int arrayCount = 0; + for (int i = 0; i < 10 && arrayCount < 2; i++) { + JsonNode msg = sub.expectNext(); + if (msg.isArray()) arrayCount++; + } + + // Request stats for both queries + TestProbe statsProbe1 = testKit.createTestProbe(); + TestProbe statsProbe2 = testKit.createTestProbe(); + + userActor.tell(new UserActor.GetStats("query1", statsProbe1.ref())); + userActor.tell(new UserActor.GetStats("query2", statsProbe2.ref())); + + // Both should receive responses + UserActor.StatsResult result1 = statsProbe1.receiveMessage(Duration.ofSeconds(5)); + UserActor.StatsResult result2 = statsProbe2.receiveMessage(Duration.ofSeconds(5)); + + assertNotNull("Should receive stats for query1", result1); + assertTrue("Stats1 should be found", result1.found()); + assertEquals("Query1 should match", "query1", result1.query()); + + assertNotNull("Should receive stats for query2", result2); + assertTrue("Stats2 should be found", result2.found()); + assertEquals("Query2 should match", "query2", result2.query()); + } + + /** + * UserActor properly delegates stats computation to StatsActor + * Tests the full flow: GetStats -> delegation -> StatsResult + * @author Dara Cunningham + */ + @Test + public void testStatsActorDelegation() throws Exception { + TestProbe> flowProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.Connect(flowProbe.ref())); + Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3)); + + // Setup TestSource and TestSink + Pair, TestSubscriber.Probe> probes = + TestSource.create(testKit.system()) + .via(flow) + .toMat(TestSink.create(testKit.system()), Keep.both()) + .run(testKit.system()); + + TestPublisher.Probe pub = probes.first(); + TestSubscriber.Probe sub = probes.second(); + + sub.request(10); + + // Send a query + JsonNode queryJson = Json.newObject() + .put("query", "delegation-query") + .put("sortBy", "publishedAt"); + pub.sendNext(queryJson); + + // Wait for WatchQuery + newsActorProbe.receiveMessage(Duration.ofSeconds(3)); + + // Add a search with articles that have content for stats + Search search = createMockSearch("delegation-query", "publishedAt", 5, 0); + userActor.tell(new UserActor.NewsResults(search)); + simulateReadabilityResponse(); + + // Skip to get history update + for (int i = 0; i < 5; i++) { + JsonNode msg = sub.expectNext(); + if (msg.isArray()) break; + } + + // Request stats - UserActor should delegate to its child StatsActor + TestProbe statsProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.GetStats("delegation-query", statsProbe.ref())); + + // The internal StatsActor should process and respond via onStatsResult + UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5)); + assertNotNull("Should receive stats result from StatsActor", result); + assertTrue("Stats should be found", result.found()); + assertEquals("Query should match", "delegation-query", result.query()); + // Stats list may be empty if mock articles don't have real content + assertNotNull("Stats counts should not be null", result.sortedCounts()); + } + + /** + * UserActor handles stats for empty article list + * Tests edge case where search exists but has no articles + * @author Dara Cunningham + */ + @Test + public void testGetStatsWithEmptyArticleList() throws Exception { + TestProbe> flowProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.Connect(flowProbe.ref())); + Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3)); + + // Setup TestSource and TestSink + Pair, TestSubscriber.Probe> probes = + TestSource.create(testKit.system()) + .via(flow) + .toMat(TestSink.create(testKit.system()), Keep.both()) + .run(testKit.system()); + + TestPublisher.Probe pub = probes.first(); + TestSubscriber.Probe sub = probes.second(); + + sub.request(10); + + // Send a query + JsonNode queryJson = Json.newObject() + .put("query", "empty-query") + .put("sortBy", "publishedAt"); + pub.sendNext(queryJson); + + // Wait for WatchQuery + newsActorProbe.receiveMessage(Duration.ofSeconds(3)); + + // Add a search with NO articles (empty list) + Search search = createMockSearch("empty-query", "publishedAt", 0, 0); + userActor.tell(new UserActor.NewsResults(search)); + simulateReadabilityResponse(); + + // Skip to get history update + for (int i = 0; i < 5; i++) { + JsonNode msg = sub.expectNext(); + if (msg.isArray()) break; + } + + // Request stats for query with empty article list + TestProbe statsProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.GetStats("empty-query", statsProbe.ref())); + + // Should still receive result (StatsActor handles empty list) + UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5)); + assertNotNull("Should receive stats result", result); + assertTrue("Stats should be found even for empty list", result.found()); + assertEquals("Query should match", "empty-query", result.query()); + assertTrue("Stats counts should be empty for no articles", result.sortedCounts().isEmpty()); + } + + /** + * Test that StatsActorResponse handler is not needed when using direct StatsResult + * This tests the removed onStatsActorResponse path - can be removed if that handler is removed + * @author Dara Cunningham + */ + @Test + public void testStatsFlowCompletesSuccessfully() throws Exception { + TestProbe> flowProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.Connect(flowProbe.ref())); + Flow flow = flowProbe.receiveMessage(Duration.ofSeconds(3)); + + Pair, TestSubscriber.Probe> probes = + TestSource.create(testKit.system()) + .via(flow) + .toMat(TestSink.create(testKit.system()), Keep.both()) + .run(testKit.system()); + + TestPublisher.Probe pub = probes.first(); + TestSubscriber.Probe sub = probes.second(); + + sub.request(10); + + // Create and populate search + JsonNode queryJson = Json.newObject() + .put("query", "complete-flow") + .put("sortBy", "popularity"); + pub.sendNext(queryJson); + + newsActorProbe.receiveMessage(Duration.ofSeconds(3)); + + Search search = createMockSearch("complete-flow", "popularity", 10, 0); + userActor.tell(new UserActor.NewsResults(search)); + simulateReadabilityResponse(); + + // Get history + for (int i = 0; i < 5; i++) { + JsonNode msg = sub.expectNext(); + if (msg.isArray()) break; + } + + // Request stats multiple times to ensure state is managed correctly + for (int round = 0; round < 3; round++) { + TestProbe statsProbe = testKit.createTestProbe(); + userActor.tell(new UserActor.GetStats("complete-flow", statsProbe.ref())); + + UserActor.StatsResult result = statsProbe.receiveMessage(Duration.ofSeconds(5)); + assertNotNull("Round " + round + ": Should receive stats", result); + assertTrue("Round " + round + ": Stats should be found", result.found()); + } + } + // === Helper Methods === /** diff --git a/test/controllers/HomeControllerTest.java b/test/controllers/HomeControllerTest.java index 6244dfa..c05996e 100644 --- a/test/controllers/HomeControllerTest.java +++ b/test/controllers/HomeControllerTest.java @@ -44,12 +44,10 @@ import play.libs.ws.WSResponse; import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CompletableFuture; -import java.util.Optional; + import static org.mockito.Mockito.*; import static org.junit.Assert.*; import static play.test.Helpers.*; @@ -279,32 +277,25 @@ public void testSource_Failure_ApiError() { * Verifies the session token is used to read history from cache. * @author Dara Cunningham */ - @Test - public void testStats() { - // First perform a search to populate history/cache for the session - String token = "unit-test-token"; - Search mockSearch = mock(Search.class); - when(mockSearch.getRawQuery()).thenReturn("testQuery"); - when(mockSearch.getSortBy()).thenReturn("popularity"); - when(mockSearch.getResults()).thenReturn(new ArrayList<>()); - - ArrayList cachedHistory = new ArrayList<>(); - cachedHistory.add(mockSearch); - when(mockCache.get(eq(token))) - .thenReturn(CompletableFuture.completedFuture(Optional.of(cachedHistory))); - - // Then call stats for the same query using the same session token + @Test + public void testStats() throws Exception { + String query = "testQuery"; + + // This is an integration test - it requires the full actor system to be running + // The test verifies the stats route returns 404 when no search has been performed + Http.RequestBuilder statsReq = new Http.RequestBuilder() .method(GET) - .uri("/search/stats?rawQuery=testQuery") - .session("user_token", token); + .uri("/search/stats?rawQuery=" + query + "&sessionId=new-session-id"); - Result statsResult = route(app, statsReq); + Result statsResult = route(app, statsReq); assertNotNull(statsResult); - assertEquals(OK, statsResult.status()); - assertTrue(contentAsString(statsResult).contains("testQuery")); // or whatever the view shows - verify(mockCache, times(1)).get(token); - } + + // Expect 404 because no search was performed for this session + assertEquals(NOT_FOUND, statsResult.status()); + assertTrue(contentAsString(statsResult).contains("Search not found")); + } + /** * Test that a WebSocket connection to the server can be successfully established and receives messages.