diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c38c427..b36b38b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,6 @@ -# CI: Maven build + unit tests on merge requests to main/master and on push to those branches. -# CD: Build Docker image and push to GitHub Container Registry (ghcr.io) on push to main/master only. +# GitLab CI pipeline for marketing-parser. +# CI: runs Maven unit tests, enforces JaCoCo coverage, and publishes reports on merge requests and protected-branch pushes. +# CD: existing publish job pushes the Docker image to ghcr.io on push to main/master. # Optional: SSH deploy when CI/CD variable ENABLE_SSH_DEPLOY=true and deploy secrets are set. # # CI/CD variables (Settings → CI/CD → Variables): @@ -33,7 +34,21 @@ build-and-test: stage: test image: maven:3.9.9-eclipse-temurin-21 script: - - mvn -B -ntp verify + - mvn -B -ntp clean verify + after_script: + - | + if [ "$CI_JOB_STATUS" != "success" ]; then + echo "ALERT: marketing-parser unit tests or JaCoCo coverage threshold failed." + fi + artifacts: + when: always + expire_in: 7 days + paths: + - target/site/jacoco + - target/surefire-reports + reports: + junit: + - target/surefire-reports/TEST-*.xml docker-build-push: stage: publish diff --git a/pom.xml b/pom.xml index d298f99..5371ed7 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ 21 + 0.8.12 @@ -202,8 +203,59 @@ - + + maven-surefire-plugin + + integration + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + check + verify + + check + + + + + CLASS + + kz.konturai.parser.service.PostingTaskService + kz.konturai.parser.service.JwtService + kz.konturai.parser.service.SocialMediaCredentialsService + + + + LINE + COVEREDRATIO + 0.80 + + + + + + + + - \ No newline at end of file + diff --git a/src/test/java/kz/konturai/parser/service/JwtServiceTest.java b/src/test/java/kz/konturai/parser/service/JwtServiceTest.java new file mode 100644 index 0000000..68b3eb2 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/JwtServiceTest.java @@ -0,0 +1,174 @@ +package kz.konturai.parser.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JwtServiceTest { + + private static final String TEST_SECRET = Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + private static final String DEFAULT_SECRET = + "dGVzdC1zZWNyZXQta2V5LWZvci1kZXZlbG9wbWVudC1vbmx5LWRvLW5vdC11c2UtaW4tcHJvZHVjdGlvbg=="; + + private JwtService jwtService; + + @BeforeEach + void setUp() { + jwtService = new JwtService(TEST_SECRET); + } + + @Test + void parseAndValidateShouldReturnClaimsForValidToken() { + String token = createToken(TEST_SECRET, "qa@example.com", Instant.now().plusSeconds(3600), Map.of( + "uid", 42L, + "roles", "ADMIN, USER")); + + Claims claims = jwtService.parseAndValidate(token); + + assertEquals("qa@example.com", claims.getSubject()); + assertEquals(42L, claims.get("uid", Long.class)); + assertEquals("ADMIN, USER", claims.get("roles", String.class)); + } + + @Test + void parseAndValidateShouldThrowForInvalidSignature() { + String token = createToken( + Base64.getEncoder().encodeToString("another-valid-secret-key-1234567".getBytes(StandardCharsets.UTF_8)), + "qa@example.com", + Instant.now().plusSeconds(3600), + Map.of("uid", 99L)); + + assertThrows(JwtException.class, () -> jwtService.parseAndValidate(token)); + } + + @Test + void constructorWithoutConfiguredSecretShouldUseDefaultSigningKey() { + JwtService serviceWithDefaultKey = new JwtService(""); + String token = createToken(DEFAULT_SECRET, "fallback@example.com", Instant.now().plusSeconds(3600), Map.of("uid", 7L)); + + assertEquals("7", serviceWithDefaultKey.extractUserIdFromToken(token)); + } + + @Test + void extractTokenFromHeaderShouldHandleBearerPrefixBlankAndRawToken() { + assertNull(jwtService.extractTokenFromHeader(null)); + assertNull(jwtService.extractTokenFromHeader(" ")); + assertEquals("sample-token", jwtService.extractTokenFromHeader("Bearer sample-token")); + assertEquals("raw-token", jwtService.extractTokenFromHeader("raw-token")); + } + + @Test + void extractUserIdFromTokenShouldPreferUidClaim() { + String token = createToken(TEST_SECRET, "qa@example.com", Instant.now().plusSeconds(3600), Map.of("uid", 12345L)); + + assertEquals("12345", jwtService.extractUserIdFromToken(token)); + } + + @ParameterizedTest + @MethodSource("legacyUserIdClaims") + void extractUserIdFromTokenShouldUseLegacyClaims(String claimName, Object claimValue, String expectedValue) { + String token = createToken(TEST_SECRET, "qa@example.com", Instant.now().plusSeconds(3600), Map.of(claimName, claimValue)); + + assertEquals(expectedValue, jwtService.extractUserIdFromToken(token)); + } + + @Test + void extractUserIdFromTokenShouldUseSubjectAsLastFallback() { + String token = createToken(TEST_SECRET, "subject@example.com", Instant.now().plusSeconds(3600), Map.of()); + + assertEquals("subject@example.com", jwtService.extractUserIdFromToken(token)); + } + + @Test + void extractUserIdFromTokenShouldReturnNullForExpiredToken() { + String token = createToken(TEST_SECRET, "qa@example.com", Instant.now().minusSeconds(60), Map.of("uid", 10L)); + + assertNull(jwtService.extractUserIdFromToken(token)); + } + + @Test + void extractUserIdFromTokenShouldReturnNullForMalformedToken() { + assertNull(jwtService.extractUserIdFromToken("not-a-jwt")); + } + + @Test + void extractUserIdFromHeaderShouldExtractFromBearerToken() { + String token = createToken(TEST_SECRET, "qa@example.com", Instant.now().plusSeconds(3600), Map.of("uid", 501L)); + + assertEquals("501", jwtService.extractUserIdFromHeader("Bearer " + token)); + } + + @Test + void extractEmailFromTokenShouldReturnSubject() { + String token = createToken(TEST_SECRET, "qa@example.com", Instant.now().plusSeconds(3600), Map.of("uid", 12L)); + + assertEquals("qa@example.com", jwtService.extractEmailFromToken(token)); + } + + @Test + void extractEmailFromTokenShouldReturnNullForInvalidToken() { + assertNull(jwtService.extractEmailFromToken("broken-token")); + } + + @Test + void extractRolesFromTokenShouldSplitAndTrimRoleList() { + String token = createToken(TEST_SECRET, "qa@example.com", Instant.now().plusSeconds(3600), Map.of( + "uid", 1L, + "roles", "ADMIN, USER , ,MANAGER")); + + assertEquals(List.of("ADMIN", "USER", "MANAGER"), jwtService.extractRolesFromToken(token)); + } + + @Test + void extractRolesFromTokenShouldReturnEmptyListWhenRolesAreMissingOrTokenInvalid() { + String tokenWithoutRoles = createToken(TEST_SECRET, "qa@example.com", Instant.now().plusSeconds(3600), Map.of("uid", 1L)); + + assertTrue(jwtService.extractRolesFromToken(tokenWithoutRoles).isEmpty()); + assertTrue(jwtService.extractRolesFromToken("invalid").isEmpty()); + assertTrue(jwtService.extractRolesFromToken(null).isEmpty()); + } + + private static Stream legacyUserIdClaims() { + return Stream.of( + Arguments.of("userId", "legacy-user", "legacy-user"), + Arguments.of("id", 55L, "55"), + Arguments.of("user_id", "external-user", "external-user")); + } + + private String createToken(String base64Secret, String subject, Instant expiration, Map customClaims) { + SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)); + Map claims = new HashMap<>(customClaims); + + var builder = Jwts.builder() + .subject(subject) + .issuedAt(Date.from(Instant.now().minusSeconds(30))) + .expiration(Date.from(expiration)) + .signWith(key); + + claims.forEach(builder::claim); + return builder.compact(); + } +} diff --git a/src/test/java/kz/konturai/parser/service/PostingTaskServiceTest.java b/src/test/java/kz/konturai/parser/service/PostingTaskServiceTest.java new file mode 100644 index 0000000..b2ae7e0 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/PostingTaskServiceTest.java @@ -0,0 +1,538 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.exception.FacebookTokenExpiredException; +import kz.konturai.parser.exception.LinkedInTokenExpiredException; +import kz.konturai.parser.exception.TelegramTokenExpiredException; +import kz.konturai.parser.model.MarketingStrategy; +import kz.konturai.parser.model.PostingTask; +import kz.konturai.parser.repository.MarketingStrategyRepository; +import kz.konturai.parser.repository.PostingTaskRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.aryEq; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PostingTaskServiceTest { + + private static final String TEST_RECIPIENT_ID = "25769470256007187"; + + @Mock + private PostingTaskRepository taskRepository; + + @Mock + private MarketingStrategyRepository strategyRepository; + + @Mock + private SocialMediaCredentialsService credentialsService; + + @Mock + private FacebookPostingService facebookPostingService; + + @Mock + private LinkedInPostingService linkedInPostingService; + + @Mock + private TelegramPostingService telegramPostingService; + + @Mock + private MinIOService minIOService; + + private PostingTaskService postingTaskService; + + @BeforeEach + void setUp() { + postingTaskService = spy(new PostingTaskService( + taskRepository, + strategyRepository, + credentialsService, + facebookPostingService, + linkedInPostingService, + telegramPostingService, + minIOService)); + } + + @Test + void createTasksFromStrategyShouldThrowWhenStrategyIsMissing() { + when(strategyRepository.findById("strategy-1")).thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> postingTaskService.createTasksFromStrategy("strategy-1")); + + assertEquals("Strategy not found: strategy-1", exception.getMessage()); + verifyNoInteractions(taskRepository, credentialsService); + } + + @Test + void createTasksFromStrategyShouldThrowWhenStrategyIsNotCompleted() { + MarketingStrategy strategy = new MarketingStrategy(); + strategy.setStatus("processing"); + when(strategyRepository.findById("strategy-1")).thenReturn(Optional.of(strategy)); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> postingTaskService.createTasksFromStrategy("strategy-1")); + + assertEquals("Strategy is not completed yet. Status: processing", exception.getMessage()); + verifyNoInteractions(credentialsService, taskRepository); + } + + @Test + void createTasksFromStrategyShouldReturnEmptyListWhenCalendarIsMissing() { + MarketingStrategy strategy = new MarketingStrategy(); + strategy.setStatus("completed"); + strategy.setPostCalendar(List.of()); + when(strategyRepository.findById("strategy-1")).thenReturn(Optional.of(strategy)); + + List tasks = postingTaskService.createTasksFromStrategy("strategy-1"); + + assertTrue(tasks.isEmpty()); + verifyNoInteractions(credentialsService); + verify(taskRepository, never()).saveAll(anyList()); + } + + @Test + void createTasksFromStrategyShouldThrowWhenCredentialsAreMissing() { + MarketingStrategy strategy = completedStrategy("user-1", List.of( + calendarItem("facebook", LocalDateTime.now().plusDays(1), "Post", List.of("#marketing")))); + + when(strategyRepository.findById("strategy-1")).thenReturn(Optional.of(strategy)); + when(credentialsService.hasCredentials("user-1", "facebook")).thenReturn(false); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> postingTaskService.createTasksFromStrategy("strategy-1")); + + assertEquals( + "Credentials not found for platform: facebook. Please configure credentials first.", + exception.getMessage()); + verify(taskRepository, never()).saveAll(anyList()); + } + + @Test + void createTasksFromStrategyShouldReturnExistingTasksWhenAlreadyCreated() { + MarketingStrategy strategy = completedStrategy("user-1", List.of( + calendarItem("facebook", LocalDateTime.now().plusDays(1), "Post", List.of("#marketing")))); + List existingTasks = List.of(task("task-1", "user-1", "facebook", "pending")); + + when(strategyRepository.findById("strategy-1")).thenReturn(Optional.of(strategy)); + when(credentialsService.hasCredentials("user-1", "facebook")).thenReturn(true); + when(taskRepository.findByStrategyId("strategy-1")).thenReturn(existingTasks); + + List result = postingTaskService.createTasksFromStrategy("strategy-1"); + + assertSame(existingTasks, result); + verify(taskRepository, never()).saveAll(anyList()); + } + + @Test + void createTasksFromStrategyShouldCreateTasksAndTriggerImmediateExecutionForPastDates() { + MarketingStrategy.PostCalendarItem pastItem = calendarItem( + "facebook", + LocalDateTime.now().minusHours(1), + "Past post", + List.of("#past")); + pastItem.setImageUrl("https://cdn.example.com/post.png"); + pastItem.setImageFilename("post.png"); + + MarketingStrategy.PostCalendarItem futureItem = calendarItem( + "telegram", + LocalDateTime.now().plusHours(2), + "Future post", + List.of("#future")); + + MarketingStrategy strategy = completedStrategy("user-1", List.of(pastItem, futureItem)); + + when(strategyRepository.findById("strategy-1")).thenReturn(Optional.of(strategy)); + when(credentialsService.hasCredentials("user-1", "facebook")).thenReturn(true); + when(credentialsService.hasCredentials("user-1", "telegram")).thenReturn(true); + when(taskRepository.findByStrategyId("strategy-1")).thenReturn(List.of()); + when(taskRepository.saveAll(anyList())).thenAnswer(invocation -> { + List tasks = invocation.getArgument(0); + tasks.get(0).setId("past-task"); + tasks.get(1).setId("future-task"); + return tasks; + }); + doNothing().when(postingTaskService).executeTaskAsync(anyString()); + + List savedTasks = postingTaskService.createTasksFromStrategy("strategy-1"); + + assertEquals(2, savedTasks.size()); + assertEquals("post.png", savedTasks.get(0).getImageFilename()); + assertEquals("https://cdn.example.com/post.png", savedTasks.get(0).getImageUrl()); + assertEquals("telegram", savedTasks.get(1).getPlatform()); + verify(postingTaskService).executeTaskAsync("past-task"); + verify(postingTaskService, never()).executeTaskAsync("future-task"); + } + + @Test + void getPendingTasksShouldDelegateToRepository() { + LocalDateTime beforeDate = LocalDateTime.now(); + List expectedTasks = List.of(task("task-1", "user-1", "facebook", "pending")); + when(taskRepository.findByStatusAndPublishDateLessThanEqual("pending", beforeDate)).thenReturn(expectedTasks); + + List actualTasks = postingTaskService.getPendingTasks(beforeDate); + + assertSame(expectedTasks, actualTasks); + } + + @Test + void executeTaskShouldIgnoreMissingTask() { + when(taskRepository.findById("task-1")).thenReturn(Optional.empty()); + + postingTaskService.executeTask("task-1"); + + verify(taskRepository, never()).save(any()); + verifyNoInteractions(credentialsService, facebookPostingService, linkedInPostingService, telegramPostingService); + } + + @Test + void executeTaskShouldSkipTaskWhenStatusIsNotPending() { + PostingTask task = task("task-1", "user-1", "facebook", "completed"); + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + + postingTaskService.executeTask("task-1"); + + verify(taskRepository, never()).save(any()); + verifyNoInteractions(credentialsService, facebookPostingService, linkedInPostingService, telegramPostingService); + } + + @Test + void executeTaskShouldCompleteFacebookTaskWithoutImage() throws Exception { + PostingTask task = task("task-1", "user-1", "facebook", "pending"); + task.setPostText("Product launch"); + task.setHashtags(List.of("#launch")); + + List savedStatuses = new ArrayList<>(); + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> { + PostingTask savedTask = invocation.getArgument(0); + savedStatuses.add(savedTask.getStatus()); + return savedTask; + }); + when(credentialsService.getCredentials("user-1", "facebook")).thenReturn("fb-token"); + when(facebookPostingService.postToPage("fb-token", "Product launch", List.of("#launch"))) + .thenReturn("fb-post-1"); + + postingTaskService.executeTask("task-1"); + + assertEquals(List.of("processing", "completed"), savedStatuses); + assertEquals("completed", task.getStatus()); + assertNull(task.getErrorMessage()); + assertNotNull(task.getExecutedAt()); + verify(facebookPostingService).postToPage("fb-token", "Product launch", List.of("#launch")); + verify(facebookPostingService).sendPrivateMessage( + eq("fb-token"), + eq(TEST_RECIPIENT_ID), + argThat(message -> message.contains("Product launch") && message.contains("https://facebook.com/fb-post-1"))); + verify(facebookPostingService, never()).sendPrivateImageMessage(anyString(), anyString(), any()); + verify(minIOService, never()).downloadFile(anyString()); + } + + @Test + void executeTaskShouldCompleteFacebookTaskWithImageAndIgnoreNotificationFailure() throws Exception { + PostingTask task = task("task-1", "user-1", "facebook", "pending"); + task.setPostText("Image post"); + task.setHashtags(List.of("#image")); + task.setImageFilename("banner.png"); + byte[] imageBytes = "binary-image".getBytes(StandardCharsets.UTF_8); + + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(credentialsService.getCredentials("user-1", "facebook")).thenReturn("fb-token"); + when(minIOService.downloadFile("banner.png")).thenReturn(streamOf(imageBytes)); + when(facebookPostingService.postToPageWithImage( + eq("fb-token"), + eq("Image post"), + eq(List.of("#image")), + aryEq(imageBytes))) + .thenReturn("fb-post-2"); + doThrow(new RuntimeException("Messenger unavailable")) + .when(facebookPostingService) + .sendPrivateMessage(eq("fb-token"), eq(TEST_RECIPIENT_ID), anyString()); + + postingTaskService.executeTask("task-1"); + + assertEquals("completed", task.getStatus()); + assertNull(task.getErrorMessage()); + verify(facebookPostingService).postToPageWithImage( + eq("fb-token"), + eq("Image post"), + eq(List.of("#image")), + aryEq(imageBytes)); + verify(facebookPostingService, never()).sendPrivateImageMessage(anyString(), anyString(), any()); + } + + @Test + void executeTaskShouldCompleteLinkedInTaskWithImage() { + PostingTask task = task("task-1", "user-1", "linkedin", "pending"); + task.setPostText("B2B update"); + task.setHashtags(List.of("#b2b")); + task.setImageFilename("banner.png"); + byte[] imageBytes = "binary-image".getBytes(StandardCharsets.UTF_8); + + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(credentialsService.getCredentials("user-1", "linkedin")).thenReturn("linkedin-token"); + when(minIOService.downloadFile("banner.png")).thenReturn(streamOf(imageBytes)); + when(linkedInPostingService.postToLinkedInWithImage( + eq("linkedin-token"), + eq("B2B update"), + eq(List.of("#b2b")), + aryEq(imageBytes), + eq("image/png"))) + .thenReturn("linkedin-post-1"); + + postingTaskService.executeTask("task-1"); + + assertEquals("completed", task.getStatus()); + assertNull(task.getErrorMessage()); + verify(linkedInPostingService).postToLinkedInWithImage( + eq("linkedin-token"), + eq("B2B update"), + eq(List.of("#b2b")), + aryEq(imageBytes), + eq("image/png")); + verify(linkedInPostingService, never()).postToLinkedIn(anyString(), anyString(), anyList()); + } + + @Test + void executeTaskShouldContinueWithoutImageWhenImageDownloadFails() { + PostingTask task = task("task-1", "user-1", "telegram", "pending"); + task.setPostText("Telegram post"); + task.setHashtags(List.of("#telegram")); + task.setImageFilename("missing.png"); + + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(credentialsService.getCredentials("user-1", "telegram")).thenReturn("{\"botToken\":\"t\",\"chatId\":\"1\"}"); + when(minIOService.downloadFile("missing.png")).thenThrow(new RuntimeException("Missing object")); + when(telegramPostingService.postToTelegram( + "{\"botToken\":\"t\",\"chatId\":\"1\"}", + "Telegram post", + List.of("#telegram"))) + .thenReturn("telegram-message-1"); + + postingTaskService.executeTask("task-1"); + + assertEquals("completed", task.getStatus()); + verify(telegramPostingService).postToTelegram( + "{\"botToken\":\"t\",\"chatId\":\"1\"}", + "Telegram post", + List.of("#telegram")); + verify(telegramPostingService, never()).postToTelegramWithImage(anyString(), anyString(), anyList(), any(), anyString()); + } + + @Test + void executeTaskShouldFailWhenCredentialsAreMissing() { + PostingTask task = task("task-1", "user-1", "facebook", "pending"); + List savedStatuses = new ArrayList<>(); + + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> { + PostingTask savedTask = invocation.getArgument(0); + savedStatuses.add(savedTask.getStatus()); + return savedTask; + }); + when(credentialsService.getCredentials("user-1", "facebook")).thenReturn(null); + + postingTaskService.executeTask("task-1"); + + assertEquals(List.of("processing", "failed"), savedStatuses); + assertEquals("failed", task.getStatus()); + assertEquals("Credentials not found for platform: facebook", task.getErrorMessage()); + } + + @Test + void executeTaskShouldFailWhenPlatformIsUnsupported() { + PostingTask task = task("task-1", "user-1", "instagram", "pending"); + + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(credentialsService.getCredentials("user-1", "instagram")).thenReturn("ig-token"); + + postingTaskService.executeTask("task-1"); + + assertEquals("failed", task.getStatus()); + assertEquals("Platform not supported: instagram", task.getErrorMessage()); + } + + @Test + void executeTaskShouldHandleFacebookExpiredToken() throws Exception { + PostingTask task = task("task-1", "user-1", "facebook", "pending"); + + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(credentialsService.getCredentials("user-1", "facebook")).thenReturn("fb-token"); + when(facebookPostingService.postToPage("fb-token", task.getPostText(), task.getHashtags())) + .thenThrow(new FacebookTokenExpiredException("Expired", "code 190", 190, 463)); + + postingTaskService.executeTask("task-1"); + + assertEquals("failed", task.getStatus()); + assertEquals("Facebook token expired: code 190", task.getErrorMessage()); + } + + @Test + void executeTaskShouldHandleLinkedInExpiredToken() { + PostingTask task = task("task-1", "user-1", "linkedin", "pending"); + + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(credentialsService.getCredentials("user-1", "linkedin")).thenReturn("linkedin-token"); + when(linkedInPostingService.postToLinkedIn("linkedin-token", task.getPostText(), task.getHashtags())) + .thenThrow(new LinkedInTokenExpiredException("Expired", "status 401", 401)); + + postingTaskService.executeTask("task-1"); + + assertEquals("failed", task.getStatus()); + assertEquals("LinkedIn token expired: status 401", task.getErrorMessage()); + } + + @Test + void executeTaskShouldHandleTelegramExpiredToken() { + PostingTask task = task("task-1", "user-1", "telegram", "pending"); + + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(credentialsService.getCredentials("user-1", "telegram")).thenReturn("{\"botToken\":\"t\",\"chatId\":\"1\"}"); + when(telegramPostingService.postToTelegram("{\"botToken\":\"t\",\"chatId\":\"1\"}", task.getPostText(), task.getHashtags())) + .thenThrow(new TelegramTokenExpiredException("Expired", "status 401", 401)); + + postingTaskService.executeTask("task-1"); + + assertEquals("failed", task.getStatus()); + assertEquals("Telegram token expired: status 401", task.getErrorMessage()); + } + + @Test + void executeTaskManuallyShouldThrowWhenTaskIsMissing() { + when(taskRepository.findById("task-1")).thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> postingTaskService.executeTaskManually("task-1")); + + assertEquals("Task not found: task-1", exception.getMessage()); + } + + @Test + void executeTaskManuallyShouldThrowWhenTaskStatusIsNotAllowed() { + PostingTask task = task("task-1", "user-1", "facebook", "completed"); + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> postingTaskService.executeTaskManually("task-1")); + + assertEquals("Task cannot be executed manually. Current status: completed", exception.getMessage()); + } + + @Test + void executeTaskManuallyShouldResetFailedTaskBeforeExecution() { + PostingTask task = task("task-1", "user-1", "facebook", "failed"); + task.setErrorMessage("Old error"); + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + when(taskRepository.save(any(PostingTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + doNothing().when(postingTaskService).executeTask("task-1"); + + postingTaskService.executeTaskManually("task-1"); + + assertEquals("pending", task.getStatus()); + assertNull(task.getErrorMessage()); + verify(taskRepository).save(task); + verify(postingTaskService).executeTask("task-1"); + } + + @Test + void executeTaskManuallyShouldExecutePendingTaskWithoutReset() { + PostingTask task = task("task-1", "user-1", "facebook", "pending"); + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + doNothing().when(postingTaskService).executeTask("task-1"); + + postingTaskService.executeTaskManually("task-1"); + + verify(taskRepository, never()).save(any()); + verify(postingTaskService).executeTask("task-1"); + } + + @Test + void queryMethodsShouldDelegateToRepository() { + PostingTask task = task("task-1", "user-1", "facebook", "pending"); + when(taskRepository.findByUserId("user-1")).thenReturn(List.of(task)); + when(taskRepository.findByStrategyId("strategy-1")).thenReturn(List.of(task)); + when(taskRepository.findById("task-1")).thenReturn(Optional.of(task)); + + assertEquals(List.of(task), postingTaskService.getUserTasks("user-1")); + assertEquals(List.of(task), postingTaskService.getStrategyTasks("strategy-1")); + assertEquals(Optional.of(task), postingTaskService.getTaskById("task-1")); + } + + private MarketingStrategy completedStrategy(String userId, List calendarItems) { + MarketingStrategy strategy = new MarketingStrategy(); + strategy.setStatus("completed"); + strategy.setUserId(userId); + strategy.setPostCalendar(calendarItems); + return strategy; + } + + private MarketingStrategy.PostCalendarItem calendarItem( + String platform, + LocalDateTime publishDate, + String postText, + List hashtags) { + MarketingStrategy.PostCalendarItem item = new MarketingStrategy.PostCalendarItem(); + item.setPlatform(platform); + item.setPublishDate(publishDate); + item.setPostText(postText); + item.setHashtags(hashtags); + return item; + } + + private PostingTask task(String id, String userId, String platform, String status) { + PostingTask task = new PostingTask(); + task.setId(id); + task.setUserId(userId); + task.setStrategyId("strategy-1"); + task.setPlatform(platform); + task.setPostText("Default post"); + task.setHashtags(List.of("#default")); + task.setPublishDate(LocalDateTime.now().plusMinutes(5)); + task.setStatus(status); + return task; + } + + private InputStream streamOf(byte[] data) { + return new ByteArrayInputStream(data); + } +} diff --git a/src/test/java/kz/konturai/parser/service/SocialMediaCredentialsServiceTest.java b/src/test/java/kz/konturai/parser/service/SocialMediaCredentialsServiceTest.java new file mode 100644 index 0000000..089162c --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/SocialMediaCredentialsServiceTest.java @@ -0,0 +1,206 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.SocialMediaCredentials; +import kz.konturai.parser.repository.SocialMediaCredentialsRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SocialMediaCredentialsServiceTest { + + @Mock + private SocialMediaCredentialsRepository repository; + + @Mock + private EncryptionService encryptionService; + + @InjectMocks + private SocialMediaCredentialsService service; + + @Test + void saveCredentialsShouldThrowWhenInputIsInvalid() { + assertThrows(IllegalArgumentException.class, () -> service.saveCredentials(null, "facebook", "token")); + assertThrows(IllegalArgumentException.class, () -> service.saveCredentials("user-1", null, "token")); + assertThrows(IllegalArgumentException.class, () -> service.saveCredentials("user-1", "facebook", "")); + } + + @Test + void saveCredentialsShouldCreateNewEntityAndEncryptCredentials() { + when(repository.findByUserIdAndPlatform("user-1", "Facebook")).thenReturn(Optional.empty()); + when(encryptionService.encrypt("plain-token")).thenReturn("encrypted-token"); + when(repository.save(any(SocialMediaCredentials.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + SocialMediaCredentials saved = service.saveCredentials("user-1", "Facebook", "plain-token"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SocialMediaCredentials.class); + verify(repository).save(captor.capture()); + SocialMediaCredentials persisted = captor.getValue(); + assertEquals("user-1", persisted.getUserId()); + assertEquals("facebook", persisted.getPlatform()); + assertEquals("encrypted-token", persisted.getEncryptedCredentials()); + assertSame(persisted, saved); + } + + @Test + void saveCredentialsShouldUpdateExistingEntity() { + SocialMediaCredentials existing = new SocialMediaCredentials("user-1", "facebook", "old-encrypted"); + when(repository.findByUserIdAndPlatform("user-1", "facebook")).thenReturn(Optional.of(existing)); + when(encryptionService.encrypt("new-token")).thenReturn("new-encrypted"); + when(repository.save(any(SocialMediaCredentials.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + SocialMediaCredentials saved = service.saveCredentials("user-1", "facebook", "new-token"); + + assertSame(existing, saved); + assertEquals("new-encrypted", existing.getEncryptedCredentials()); + assertEquals("facebook", existing.getPlatform()); + } + + @Test + void getCredentialsShouldReturnNullWhenInputIsInvalid() { + assertNull(service.getCredentials(null, "facebook")); + assertNull(service.getCredentials("user-1", null)); + } + + @Test + void getCredentialsShouldReturnNullWhenCredentialsAreMissing() { + when(repository.findByUserIdAndPlatform("user-1", "facebook")).thenReturn(Optional.empty()); + + assertNull(service.getCredentials("user-1", "FACEBOOK")); + } + + @Test + void getCredentialsShouldDecryptStringCredentials() { + SocialMediaCredentials entity = new SocialMediaCredentials("user-1", "facebook", "encrypted-token"); + when(repository.findByUserIdAndPlatform("user-1", "facebook")).thenReturn(Optional.of(entity)); + when(encryptionService.decrypt("encrypted-token")).thenReturn("plain-token"); + + assertEquals("plain-token", service.getCredentials("user-1", "FACEBOOK")); + } + + @Test + void getCredentialsShouldDecryptNonStringCredentialsViaToString() { + SocialMediaCredentials entity = new SocialMediaCredentials("user-1", "telegram", new StringBuilder("encrypted-json")); + when(repository.findByUserIdAndPlatform("user-1", "telegram")).thenReturn(Optional.of(entity)); + when(encryptionService.decrypt("encrypted-json")).thenReturn("{\"botToken\":\"t\"}"); + + assertEquals("{\"botToken\":\"t\"}", service.getCredentials("user-1", "telegram")); + } + + @Test + void getCredentialsShouldWrapDecryptionFailures() { + SocialMediaCredentials entity = new SocialMediaCredentials("user-1", "facebook", "encrypted-token"); + when(repository.findByUserIdAndPlatform("user-1", "facebook")).thenReturn(Optional.of(entity)); + when(encryptionService.decrypt("encrypted-token")).thenThrow(new RuntimeException("decrypt failed")); + + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> service.getCredentials("user-1", "facebook")); + + assertEquals("Failed to decrypt credentials", exception.getMessage()); + assertNotNull(exception.getCause()); + } + + @Test + void hasCredentialsShouldReturnFalseForInvalidInput() { + assertTrue(!service.hasCredentials(null, "facebook")); + assertTrue(!service.hasCredentials("user-1", null)); + } + + @Test + void hasCredentialsShouldCheckRepositoryUsingLowercasePlatform() { + when(repository.findByUserIdAndPlatform("user-1", "facebook")) + .thenReturn(Optional.of(new SocialMediaCredentials("user-1", "facebook", "encrypted"))); + + assertTrue(service.hasCredentials("user-1", "FACEBOOK")); + } + + @Test + void deleteCredentialsShouldThrowForInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> service.deleteCredentials(null, "facebook")); + assertThrows(IllegalArgumentException.class, () -> service.deleteCredentials("user-1", null)); + } + + @Test + void deleteCredentialsShouldUseLowercasePlatform() { + service.deleteCredentials("user-1", "FACEBOOK"); + + verify(repository).deleteByUserIdAndPlatform("user-1", "facebook"); + } + + @Test + void getUserPlatformsShouldReturnEmptyListForNullUserId() { + assertEquals(List.of(), service.getUserPlatforms(null)); + verify(repository, never()).findByUserId(any()); + } + + @Test + void getUserPlatformsShouldReturnAllPlatforms() { + when(repository.findByUserId("user-1")).thenReturn(List.of( + new SocialMediaCredentials("user-1", "facebook", "e1"), + new SocialMediaCredentials("user-1", "telegram", "e2"))); + + assertEquals(List.of("facebook", "telegram"), service.getUserPlatforms("user-1")); + } + + @Test + void getUserCredentialsShouldReturnEmptyListForNullUserId() { + assertEquals(List.of(), service.getUserCredentials(null)); + verify(repository, never()).findByUserId(any()); + } + + @Test + void getUserCredentialsShouldDelegateToRepository() { + List credentials = List.of(new SocialMediaCredentials("user-1", "facebook", "encrypted")); + when(repository.findByUserId("user-1")).thenReturn(credentials); + + assertSame(credentials, service.getUserCredentials("user-1")); + } + + @Test + void updateAdAccountIdShouldThrowForInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> service.updateAdAccountId(null, "facebook", "act-1")); + assertThrows(IllegalArgumentException.class, () -> service.updateAdAccountId("user-1", null, "act-1")); + assertThrows(IllegalArgumentException.class, () -> service.updateAdAccountId("user-1", "facebook", null)); + } + + @Test + void updateAdAccountIdShouldThrowWhenCredentialsAreMissing() { + when(repository.findByUserIdAndPlatform("user-1", "facebook")).thenReturn(Optional.empty()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> service.updateAdAccountId("user-1", "FACEBOOK", "act-1")); + + assertEquals("Credentials not found for user user-1 and platform FACEBOOK", exception.getMessage()); + } + + @Test + void updateAdAccountIdShouldUpdateAndSaveCredentials() { + SocialMediaCredentials credentials = new SocialMediaCredentials("user-1", "facebook", "encrypted"); + when(repository.findByUserIdAndPlatform("user-1", "facebook")).thenReturn(Optional.of(credentials)); + when(repository.save(any(SocialMediaCredentials.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + service.updateAdAccountId("user-1", "FACEBOOK", "act-123"); + + assertEquals("act-123", credentials.getAdAccountId()); + verify(repository).save(credentials); + } +}