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);
+ }
+}