topicResponses = topicsListener.getAllTopics();
diff --git a/src/main/java/com/hcmus/forumus_backend/service/SummaryCacheService.java b/src/main/java/com/hcmus/forumus_backend/service/SummaryCacheService.java
new file mode 100644
index 0000000..2ee9993
--- /dev/null
+++ b/src/main/java/com/hcmus/forumus_backend/service/SummaryCacheService.java
@@ -0,0 +1,264 @@
+package com.hcmus.forumus_backend.service;
+
+import org.springframework.stereotype.Service;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.Base64;
+
+/**
+ * Service for caching AI-generated summaries to avoid redundant API calls.
+ *
+ * Architecture Overview:
+ *
+ * - Uses in-memory ConcurrentHashMap for fast, thread-safe caching
+ * - Tracks content hash to detect when post content changes
+ * - Automatically invalidates cache when content changes
+ * - Supports TTL (Time-To-Live) for cache entries
+ *
+ *
+ * Cache Entry Structure:
+ *
+ * - Key: postId
+ * - Value: CachedSummary containing summary, contentHash, and timestamp
+ *
+ */
+@Service
+public class SummaryCacheService {
+
+ /**
+ * Internal class representing a cached summary entry.
+ */
+ public static class CachedSummary {
+ private final String summary;
+ private final String contentHash;
+ private final long createdAt;
+ private long lastAccessedAt;
+ private int hitCount;
+
+ public CachedSummary(String summary, String contentHash) {
+ this.summary = summary;
+ this.contentHash = contentHash;
+ this.createdAt = System.currentTimeMillis();
+ this.lastAccessedAt = this.createdAt;
+ this.hitCount = 0;
+ }
+
+ public String getSummary() {
+ return summary;
+ }
+
+ public String getContentHash() {
+ return contentHash;
+ }
+
+ public long getCreatedAt() {
+ return createdAt;
+ }
+
+ public long getLastAccessedAt() {
+ return lastAccessedAt;
+ }
+
+ public int getHitCount() {
+ return hitCount;
+ }
+
+ public void recordAccess() {
+ this.lastAccessedAt = System.currentTimeMillis();
+ this.hitCount++;
+ }
+
+ public boolean isExpired(long ttlMillis) {
+ return System.currentTimeMillis() - createdAt > ttlMillis;
+ }
+ }
+
+ /**
+ * Cache statistics for monitoring and optimization.
+ */
+ public static class CacheStats {
+ private long hits;
+ private long misses;
+ private long invalidations;
+ private long evictions;
+
+ public long getHits() { return hits; }
+ public long getMisses() { return misses; }
+ public long getInvalidations() { return invalidations; }
+ public long getEvictions() { return evictions; }
+ public double getHitRate() {
+ long total = hits + misses;
+ return total > 0 ? (double) hits / total : 0.0;
+ }
+
+ public void recordHit() { hits++; }
+ public void recordMiss() { misses++; }
+ public void recordInvalidation() { invalidations++; }
+ public void recordEviction() { evictions++; }
+ }
+
+ // Cache storage: postId -> CachedSummary
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ // Cache configuration
+ private static final long DEFAULT_TTL_MILLIS = 24 * 60 * 60 * 1000; // 24 hours
+ private static final int MAX_CACHE_SIZE = 10000; // Maximum entries
+
+ // Cache statistics
+ private final CacheStats stats = new CacheStats();
+
+ /**
+ * Computes a SHA-256 hash of the given content.
+ * Used to detect when post content has changed.
+ *
+ * @param content The content to hash
+ * @return Base64-encoded hash string
+ */
+ public String computeContentHash(String title, String content) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ String combined = (title != null ? title : "") + "|" + (content != null ? content : "");
+ byte[] hashBytes = digest.digest(combined.getBytes());
+ return Base64.getEncoder().encodeToString(hashBytes);
+ } catch (NoSuchAlgorithmException e) {
+ // Fallback to simple hash
+ return String.valueOf((title + "|" + content).hashCode());
+ }
+ }
+
+ /**
+ * Gets a cached summary if it exists and is still valid.
+ *
+ * @param postId The post ID
+ * @param currentContentHash The current content hash to validate against
+ * @return The cached summary if valid, null otherwise
+ */
+ public CachedSummary get(String postId, String currentContentHash) {
+ CachedSummary cached = cache.get(postId);
+
+ if (cached == null) {
+ stats.recordMiss();
+ return null;
+ }
+
+ // Check if content has changed (invalidation)
+ if (!cached.getContentHash().equals(currentContentHash)) {
+ invalidate(postId);
+ stats.recordInvalidation();
+ stats.recordMiss();
+ return null;
+ }
+
+ // Check TTL expiration
+ if (cached.isExpired(DEFAULT_TTL_MILLIS)) {
+ invalidate(postId);
+ stats.recordEviction();
+ stats.recordMiss();
+ return null;
+ }
+
+ // Valid cache hit
+ cached.recordAccess();
+ stats.recordHit();
+ return cached;
+ }
+
+ /**
+ * Stores a summary in the cache.
+ *
+ * @param postId The post ID
+ * @param summary The generated summary
+ * @param contentHash The content hash at the time of generation
+ */
+ public void put(String postId, String summary, String contentHash) {
+ // Evict old entries if cache is full
+ if (cache.size() >= MAX_CACHE_SIZE) {
+ evictOldestEntries();
+ }
+
+ cache.put(postId, new CachedSummary(summary, contentHash));
+ }
+
+ /**
+ * Invalidates a specific cache entry.
+ *
+ * @param postId The post ID to invalidate
+ */
+ public void invalidate(String postId) {
+ cache.remove(postId);
+ }
+
+ /**
+ * Clears the entire cache.
+ */
+ public void clear() {
+ cache.clear();
+ }
+
+ /**
+ * Gets the current cache size.
+ *
+ * @return Number of cached entries
+ */
+ public int size() {
+ return cache.size();
+ }
+
+ /**
+ * Gets cache statistics.
+ *
+ * @return CacheStats object with hit/miss information
+ */
+ public CacheStats getStats() {
+ return stats;
+ }
+
+ /**
+ * Checks if a post has a valid cached summary.
+ *
+ * @param postId The post ID
+ * @param currentContentHash The current content hash
+ * @return true if a valid cache entry exists
+ */
+ public boolean hasValidCache(String postId, String currentContentHash) {
+ CachedSummary cached = cache.get(postId);
+ if (cached == null) {
+ return false;
+ }
+ return cached.getContentHash().equals(currentContentHash) && !cached.isExpired(DEFAULT_TTL_MILLIS);
+ }
+
+ /**
+ * Evicts the oldest 10% of cache entries when cache is full.
+ * Uses last-accessed time for LRU-like behavior.
+ */
+ private void evictOldestEntries() {
+ int entriesToEvict = MAX_CACHE_SIZE / 10; // Evict 10%
+
+ cache.entrySet().stream()
+ .sorted((e1, e2) -> Long.compare(e1.getValue().getLastAccessedAt(), e2.getValue().getLastAccessedAt()))
+ .limit(entriesToEvict)
+ .forEach(entry -> {
+ cache.remove(entry.getKey());
+ stats.recordEviction();
+ });
+ }
+
+ /**
+ * Gets a summary of the cache state for monitoring.
+ *
+ * @return String describing cache state
+ */
+ public String getCacheStatusSummary() {
+ return String.format(
+ "Cache Status: size=%d, hits=%d, misses=%d, hitRate=%.2f%%, invalidations=%d, evictions=%d",
+ size(),
+ stats.getHits(),
+ stats.getMisses(),
+ stats.getHitRate() * 100,
+ stats.getInvalidations(),
+ stats.getEvictions()
+ );
+ }
+}
diff --git a/src/test/java/com/hcmus/forumus_backend/controller/PostControllerSummarizeTest.java b/src/test/java/com/hcmus/forumus_backend/controller/PostControllerSummarizeTest.java
new file mode 100644
index 0000000..c9e5eb8
--- /dev/null
+++ b/src/test/java/com/hcmus/forumus_backend/controller/PostControllerSummarizeTest.java
@@ -0,0 +1,201 @@
+package com.hcmus.forumus_backend.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.hcmus.forumus_backend.dto.post.PostSummaryRequest;
+import com.hcmus.forumus_backend.dto.post.PostSummaryResponse;
+import com.hcmus.forumus_backend.service.PostService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+/**
+ * Unit tests for PostController summarize endpoint.
+ * Tests various input scenarios and response handling.
+ */
+@ExtendWith(MockitoExtension.class)
+class PostControllerSummarizeTest {
+
+ private MockMvc mockMvc;
+
+ @Mock
+ private PostService postService;
+
+ @Mock
+ private com.hcmus.forumus_backend.service.NotificationService notificationService;
+
+ @InjectMocks
+ private PostController postController;
+
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(postController).build();
+ objectMapper = new ObjectMapper();
+ }
+
+ @Test
+ @DisplayName("POST /api/posts/summarize - Valid postId returns summary")
+ void summarizePost_ValidPostId_ReturnsSummary() throws Exception {
+ // Arrange
+ String postId = "test-post-123";
+ String expectedSummary = "This is a comprehensive summary of the post content.";
+ PostSummaryRequest request = new PostSummaryRequest(postId);
+ PostSummaryResponse response = PostSummaryResponse.success(expectedSummary, false);
+
+ when(postService.summarizePost(postId)).thenReturn(response);
+
+ // Act & Assert
+ mockMvc.perform(post("/api/posts/summarize")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.summary").value(expectedSummary))
+ .andExpect(jsonPath("$.cached").value(false))
+ .andExpect(jsonPath("$.errorMessage").doesNotExist());
+
+ verify(postService, times(1)).summarizePost(postId);
+ }
+
+ @Test
+ @DisplayName("POST /api/posts/summarize - Non-existent postId returns error")
+ void summarizePost_NonExistentPostId_ReturnsError() throws Exception {
+ // Arrange
+ String postId = "non-existent-post";
+ PostSummaryRequest request = new PostSummaryRequest(postId);
+ PostSummaryResponse response = PostSummaryResponse.error("Post not found");
+
+ when(postService.summarizePost(postId)).thenReturn(response);
+
+ // Act & Assert
+ mockMvc.perform(post("/api/posts/summarize")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.errorMessage").value("Post not found"))
+ .andExpect(jsonPath("$.summary").doesNotExist());
+
+ verify(postService, times(1)).summarizePost(postId);
+ }
+
+ @Test
+ @DisplayName("POST /api/posts/summarize - Empty postId returns error")
+ void summarizePost_EmptyPostId_ReturnsError() throws Exception {
+ // Arrange
+ PostSummaryRequest request = new PostSummaryRequest("");
+ PostSummaryResponse response = PostSummaryResponse.error("Post not found");
+
+ when(postService.summarizePost("")).thenReturn(response);
+
+ // Act & Assert
+ mockMvc.perform(post("/api/posts/summarize")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(false));
+
+ verify(postService, times(1)).summarizePost("");
+ }
+
+ @Test
+ @DisplayName("POST /api/posts/summarize - Cached response returns cached flag")
+ void summarizePost_CachedResponse_ReturnsCachedFlag() throws Exception {
+ // Arrange
+ String postId = "cached-post-123";
+ String cachedSummary = "This summary was retrieved from cache.";
+ PostSummaryRequest request = new PostSummaryRequest(postId);
+ PostSummaryResponse response = PostSummaryResponse.success(cachedSummary, true);
+
+ when(postService.summarizePost(postId)).thenReturn(response);
+
+ // Act & Assert
+ mockMvc.perform(post("/api/posts/summarize")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.summary").value(cachedSummary))
+ .andExpect(jsonPath("$.cached").value(true));
+
+ verify(postService, times(1)).summarizePost(postId);
+ }
+
+ @Test
+ @DisplayName("POST /api/posts/summarize - AI service failure returns error")
+ void summarizePost_AIServiceFailure_ReturnsError() throws Exception {
+ // Arrange
+ String postId = "test-post-456";
+ PostSummaryRequest request = new PostSummaryRequest(postId);
+ PostSummaryResponse response = PostSummaryResponse.error("Failed to generate summary: AI service unavailable");
+
+ when(postService.summarizePost(postId)).thenReturn(response);
+
+ // Act & Assert
+ mockMvc.perform(post("/api/posts/summarize")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.errorMessage").value("Failed to generate summary: AI service unavailable"));
+
+ verify(postService, times(1)).summarizePost(postId);
+ }
+
+ @Test
+ @DisplayName("POST /api/posts/summarize - Long content handled gracefully")
+ void summarizePost_LongContent_HandledGracefully() throws Exception {
+ // Arrange
+ String postId = "long-content-post";
+ String longSummary = "This summary was generated from a very long post. ".repeat(10);
+ PostSummaryRequest request = new PostSummaryRequest(postId);
+ PostSummaryResponse response = PostSummaryResponse.success(longSummary, false);
+
+ when(postService.summarizePost(postId)).thenReturn(response);
+
+ // Act & Assert
+ mockMvc.perform(post("/api/posts/summarize")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.summary").isNotEmpty());
+
+ verify(postService, times(1)).summarizePost(postId);
+ }
+
+ @Test
+ @DisplayName("POST /api/posts/summarize - Unicode content in summary")
+ void summarizePost_UnicodeContent_HandledCorrectly() throws Exception {
+ // Arrange
+ String postId = "unicode-post";
+ String unicodeSummary = "这是一个测试摘要,包含中文内容。日本語も含まれています。🎉";
+ PostSummaryRequest request = new PostSummaryRequest(postId);
+ PostSummaryResponse response = PostSummaryResponse.success(unicodeSummary, false);
+
+ when(postService.summarizePost(postId)).thenReturn(response);
+
+ // Act & Assert
+ mockMvc.perform(post("/api/posts/summarize")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.summary").value(unicodeSummary));
+
+ verify(postService, times(1)).summarizePost(postId);
+ }
+}
diff --git a/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequestTest.java b/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequestTest.java
new file mode 100644
index 0000000..300a635
--- /dev/null
+++ b/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequestTest.java
@@ -0,0 +1,76 @@
+package com.hcmus.forumus_backend.dto.post;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.DisplayName;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for PostSummaryRequest DTO.
+ * Verifies constructor, getters, setters, and edge cases.
+ */
+class PostSummaryRequestTest {
+
+ @Test
+ @DisplayName("Default constructor creates empty request")
+ void defaultConstructor_CreatesEmptyRequest() {
+ PostSummaryRequest request = new PostSummaryRequest();
+
+ assertNull(request.getPostId());
+ }
+
+ @Test
+ @DisplayName("Parameterized constructor sets postId correctly")
+ void parameterizedConstructor_SetsPostId() {
+ String postId = "test-post-123";
+
+ PostSummaryRequest request = new PostSummaryRequest(postId);
+
+ assertEquals(postId, request.getPostId());
+ }
+
+ @Test
+ @DisplayName("setPostId updates the postId value")
+ void setPostId_UpdatesValue() {
+ PostSummaryRequest request = new PostSummaryRequest();
+ String postId = "updated-post-456";
+
+ request.setPostId(postId);
+
+ assertEquals(postId, request.getPostId());
+ }
+
+ @Test
+ @DisplayName("Handles empty string postId")
+ void emptyString_PostId() {
+ PostSummaryRequest request = new PostSummaryRequest("");
+
+ assertEquals("", request.getPostId());
+ }
+
+ @Test
+ @DisplayName("Handles null postId")
+ void nullPostId_IsAllowed() {
+ PostSummaryRequest request = new PostSummaryRequest(null);
+
+ assertNull(request.getPostId());
+ }
+
+ @Test
+ @DisplayName("Handles long postId")
+ void longPostId_IsAllowed() {
+ String longId = "a".repeat(1000);
+ PostSummaryRequest request = new PostSummaryRequest(longId);
+
+ assertEquals(longId, request.getPostId());
+ assertEquals(1000, request.getPostId().length());
+ }
+
+ @Test
+ @DisplayName("Handles special characters in postId")
+ void specialCharacters_InPostId() {
+ String specialId = "post-123_abc@#$%";
+ PostSummaryRequest request = new PostSummaryRequest(specialId);
+
+ assertEquals(specialId, request.getPostId());
+ }
+}
diff --git a/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponseTest.java b/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponseTest.java
new file mode 100644
index 0000000..6c8a80e
--- /dev/null
+++ b/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponseTest.java
@@ -0,0 +1,137 @@
+package com.hcmus.forumus_backend.dto.post;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.DisplayName;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for PostSummaryResponse DTO.
+ * Verifies constructors, factory methods, getters, setters, and edge cases.
+ */
+class PostSummaryResponseTest {
+
+ @Test
+ @DisplayName("Default constructor creates empty response")
+ void defaultConstructor_CreatesEmptyResponse() {
+ PostSummaryResponse response = new PostSummaryResponse();
+
+ assertFalse(response.isSuccess());
+ assertNull(response.getSummary());
+ assertNull(response.getErrorMessage());
+ assertFalse(response.isCached());
+ }
+
+ @Test
+ @DisplayName("Parameterized constructor sets all fields correctly")
+ void parameterizedConstructor_SetsAllFields() {
+ PostSummaryResponse response = new PostSummaryResponse(true, "Test summary", null, true);
+
+ assertTrue(response.isSuccess());
+ assertEquals("Test summary", response.getSummary());
+ assertNull(response.getErrorMessage());
+ assertTrue(response.isCached());
+ }
+
+ @Test
+ @DisplayName("success() factory method creates successful response")
+ void successFactory_CreatesSuccessfulResponse() {
+ String summary = "This is a test summary of the post content.";
+
+ PostSummaryResponse response = PostSummaryResponse.success(summary, false);
+
+ assertTrue(response.isSuccess());
+ assertEquals(summary, response.getSummary());
+ assertNull(response.getErrorMessage());
+ assertFalse(response.isCached());
+ }
+
+ @Test
+ @DisplayName("success() factory method with cached flag")
+ void successFactory_WithCachedFlag() {
+ String summary = "Cached summary";
+
+ PostSummaryResponse response = PostSummaryResponse.success(summary, true);
+
+ assertTrue(response.isSuccess());
+ assertEquals(summary, response.getSummary());
+ assertTrue(response.isCached());
+ }
+
+ @Test
+ @DisplayName("error() factory method creates error response")
+ void errorFactory_CreatesErrorResponse() {
+ String errorMessage = "Post not found";
+
+ PostSummaryResponse response = PostSummaryResponse.error(errorMessage);
+
+ assertFalse(response.isSuccess());
+ assertNull(response.getSummary());
+ assertEquals(errorMessage, response.getErrorMessage());
+ assertFalse(response.isCached());
+ }
+
+ @Test
+ @DisplayName("Setters update fields correctly")
+ void setters_UpdateFields() {
+ PostSummaryResponse response = new PostSummaryResponse();
+
+ response.setSuccess(true);
+ response.setSummary("Updated summary");
+ response.setErrorMessage("Updated error");
+ response.setCached(true);
+
+ assertTrue(response.isSuccess());
+ assertEquals("Updated summary", response.getSummary());
+ assertEquals("Updated error", response.getErrorMessage());
+ assertTrue(response.isCached());
+ }
+
+ @Test
+ @DisplayName("Handles empty summary")
+ void emptySummary_IsAllowed() {
+ PostSummaryResponse response = PostSummaryResponse.success("", false);
+
+ assertTrue(response.isSuccess());
+ assertEquals("", response.getSummary());
+ }
+
+ @Test
+ @DisplayName("Handles long summary text")
+ void longSummary_IsAllowed() {
+ String longSummary = "This is a very detailed summary. ".repeat(100);
+
+ PostSummaryResponse response = PostSummaryResponse.success(longSummary, false);
+
+ assertTrue(response.isSuccess());
+ assertEquals(longSummary, response.getSummary());
+ }
+
+ @Test
+ @DisplayName("Handles unicode characters in summary")
+ void unicodeSummary_IsAllowed() {
+ String unicodeSummary = "这是一个测试摘要 🎉 с русскими буквами";
+
+ PostSummaryResponse response = PostSummaryResponse.success(unicodeSummary, false);
+
+ assertTrue(response.isSuccess());
+ assertEquals(unicodeSummary, response.getSummary());
+ }
+
+ @Test
+ @DisplayName("Error response with empty error message")
+ void emptyErrorMessage_IsAllowed() {
+ PostSummaryResponse response = PostSummaryResponse.error("");
+
+ assertFalse(response.isSuccess());
+ assertEquals("", response.getErrorMessage());
+ }
+
+ @Test
+ @DisplayName("Error response with null error message")
+ void nullErrorMessage_IsAllowed() {
+ PostSummaryResponse response = PostSummaryResponse.error(null);
+
+ assertFalse(response.isSuccess());
+ assertNull(response.getErrorMessage());
+ }
+}