diff --git a/src/main/java/com/jobtracker/dto/gamification/GamificationEventSummary.java b/src/main/java/com/jobtracker/dto/gamification/GamificationEventSummary.java new file mode 100644 index 00000000..dfb9aee6 --- /dev/null +++ b/src/main/java/com/jobtracker/dto/gamification/GamificationEventSummary.java @@ -0,0 +1,26 @@ +package com.jobtracker.dto.gamification; + +import com.jobtracker.entity.enums.GamificationEventType; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "Aggregated result returned by the MCP gamification tool") +public record GamificationEventSummary( + @Schema(description = "Processed event type") + GamificationEventType eventType, + @Schema(description = "XP awarded for this event") + int xpGained, + @Schema(description = "Whether the event caused a level up") + boolean leveledUp, + @Schema(description = "New level after the event") + int newLevel, + @Schema(description = "Current streak in days") + int streakDays, + @Schema(description = "Achievement codes newly unlocked during this event") + List newlyUnlockedAchievements, + @Schema(description = "Human-readable feedback") + String message, + @Schema(description = "Full updated profile snapshot") + GamificationProfileResponse profile +) {} diff --git a/src/main/java/com/jobtracker/gamification/GamificationProgressCallback.java b/src/main/java/com/jobtracker/gamification/GamificationProgressCallback.java new file mode 100644 index 00000000..692eb7cd --- /dev/null +++ b/src/main/java/com/jobtracker/gamification/GamificationProgressCallback.java @@ -0,0 +1,16 @@ +package com.jobtracker.gamification; + +/** + * Callback fired by GamificationService at key milestones during event processing. + * The MCP layer implements this to translate milestones into protocol progress notifications. + */ +@FunctionalInterface +public interface GamificationProgressCallback { + + /** + * @param step 1-based step number + * @param total total expected steps + * @param message human-readable description of the milestone + */ + void onStep(int step, int total, String message); +} diff --git a/src/main/java/com/jobtracker/mcp/tools/McpGamificationTools.java b/src/main/java/com/jobtracker/mcp/tools/McpGamificationTools.java new file mode 100644 index 00000000..a15e631f --- /dev/null +++ b/src/main/java/com/jobtracker/mcp/tools/McpGamificationTools.java @@ -0,0 +1,74 @@ +package com.jobtracker.mcp.tools; + +import com.jobtracker.dto.gamification.GamificationEventRequest; +import com.jobtracker.dto.gamification.GamificationEventSummary; +import com.jobtracker.entity.enums.GamificationEventType; +import com.jobtracker.gamification.GamificationProgressCallback; +import com.jobtracker.service.GamificationService; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpTool.McpAnnotations; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springaicommunity.mcp.context.McpSyncRequestContext; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Component +public class McpGamificationTools { + + private final GamificationService gamificationService; + + public McpGamificationTools(GamificationService gamificationService) { + this.gamificationService = gamificationService; + } + + @McpTool( + name = "Apply-Gamification-Event", + title = "Apply Gamification Event", + description = """ + Apply a gamification event for the authenticated user and return an aggregated summary + that includes XP gained, new level, streak changes, and any newly unlocked achievements. + Emits incremental progress notifications during processing when the client supports it. + """, + annotations = @McpAnnotations( + title = "Apply Gamification Event", + readOnlyHint = false, + destructiveHint = false, + idempotentHint = false, + openWorldHint = false)) + public GamificationEventSummary applyGamificationEvent( + McpSyncRequestContext mcpContext, + @McpToolParam(required = true, + description = "Event type: APPLICATION_CREATED, RECRUITER_DM_SENT, INTERVIEW_PROGRESS, NOTE_ADDED, OFFER_WON") + String eventType, + @McpToolParam(required = false, + description = "UUID of the related application — required for application-scoped events") + String applicationId, + @McpToolParam(required = false, + description = "ISO-8601 timestamp when the event occurred (defaults to now), e.g. 2025-06-05T10:30:00") + String occurredAt) { + + GamificationEventType type = GamificationEventType.valueOf(eventType.trim().toUpperCase()); + UUID appId = applicationId != null && !applicationId.isBlank() ? UUID.fromString(applicationId) : null; + LocalDateTime when = occurredAt != null && !occurredAt.isBlank() ? LocalDateTime.parse(occurredAt) : null; + + GamificationEventRequest request = new GamificationEventRequest(type, appId, when); + + GamificationProgressCallback callback = buildCallback(mcpContext); + return gamificationService.applyEventWithProgress(request, callback); + } + + private GamificationProgressCallback buildCallback(McpSyncRequestContext ctx) { + if (ctx == null) { + return (step, total, message) -> {}; + } + return (step, total, message) -> { + try { + ctx.progress(spec -> spec.progress(step).total(total).message(message)); + } catch (Exception ignored) { + // progress notifications are best-effort; a missing progressToken is not fatal + } + }; + } +} diff --git a/src/main/java/com/jobtracker/service/GamificationService.java b/src/main/java/com/jobtracker/service/GamificationService.java index e1780714..5c7bfb0b 100644 --- a/src/main/java/com/jobtracker/service/GamificationService.java +++ b/src/main/java/com/jobtracker/service/GamificationService.java @@ -3,7 +3,9 @@ import com.jobtracker.dto.gamification.AchievementResponse; import com.jobtracker.dto.gamification.GamificationEventRequest; import com.jobtracker.dto.gamification.GamificationEventResponse; +import com.jobtracker.dto.gamification.GamificationEventSummary; import com.jobtracker.dto.gamification.GamificationProfileResponse; +import com.jobtracker.gamification.GamificationProgressCallback; import com.jobtracker.entity.Achievement; import com.jobtracker.entity.JobApplication; import com.jobtracker.entity.User; @@ -142,6 +144,56 @@ public GamificationEventResponse applyEvent(GamificationEventRequest request) { return toEventResponse(request.eventType(), result); } + /** + * Progress-aware variant used by the MCP tool layer. + * Fires callback at four milestones so the MCP adapter can emit protocol progress notifications. + */ + @Transactional + public GamificationEventSummary applyEventWithProgress(GamificationEventRequest request, + GamificationProgressCallback callback) { + User user = securityUtils.getCurrentUser(); + LocalDateTime occurredAt = request.occurredAt() != null ? request.occurredAt() : LocalDateTime.now(); + + callback.onStep(1, 4, "Validating event eligibility"); + + Set beforeUnlocked = userAchievementRepository.findAllByUser_IdOrderByAchievedAtDesc(user.getId()) + .stream() + .filter(ua -> ua.getAchievement() != null) + .map(ua -> ua.getAchievement().getCode()) + .collect(Collectors.toSet()); + + callback.onStep(2, 4, "Awarding XP"); + + EventResult result = request.applicationId() != null + ? applyApplicationEvent(user, request.applicationId(), request.eventType(), occurredAt) + : grantXp(user, request.eventType(), occurredAt); + + callback.onStep(3, 4, "Checking achievements"); + + Set afterUnlocked = userAchievementRepository.findAllByUser_IdOrderByAchievedAtDesc(user.getId()) + .stream() + .filter(ua -> ua.getAchievement() != null) + .map(ua -> ua.getAchievement().getCode()) + .collect(Collectors.toSet()); + + List newlyUnlocked = afterUnlocked.stream() + .filter(code -> !beforeUnlocked.contains(code)) + .toList(); + + callback.onStep(4, 4, "Building summary"); + + return new GamificationEventSummary( + request.eventType(), + result.xpAwarded(), + result.leveledUp(), + result.profile().level(), + result.profile().streakDays(), + newlyUnlocked, + result.message(), + result.profile() + ); + } + @Transactional public void onApplicationCreated(JobApplication application) { awardApplicationCreated(application, application.getCreatedAt() != null ? application.getCreatedAt() : LocalDateTime.now()); diff --git a/src/test/java/com/jobtracker/unit/mcp/McpGamificationToolsTest.java b/src/test/java/com/jobtracker/unit/mcp/McpGamificationToolsTest.java new file mode 100644 index 00000000..8352ae8c --- /dev/null +++ b/src/test/java/com/jobtracker/unit/mcp/McpGamificationToolsTest.java @@ -0,0 +1,180 @@ +package com.jobtracker.unit.mcp; + +import com.jobtracker.dto.gamification.GamificationEventRequest; +import com.jobtracker.dto.gamification.GamificationEventSummary; +import com.jobtracker.dto.gamification.GamificationProfileResponse; +import com.jobtracker.entity.enums.GamificationEventType; +import com.jobtracker.gamification.GamificationProgressCallback; +import com.jobtracker.mcp.tools.McpGamificationTools; +import com.jobtracker.service.GamificationService; +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 org.springaicommunity.mcp.context.McpSyncRequestContext; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class McpGamificationToolsTest { + + @Mock + private GamificationService gamificationService; + + @Mock + private McpSyncRequestContext mcpContext; + + @InjectMocks + private McpGamificationTools tools; + + private static GamificationEventSummary sampleSummary(GamificationEventType type) { + GamificationProfileResponse profile = new GamificationProfileResponse( + 110, 2, 100, 400, 290, 2, "Job Hunter Iniciante", 3); + return new GamificationEventSummary( + type, 10, false, 2, 3, List.of(), "+10 XP por registrar uma nova aplicacao.", profile); + } + + private static GamificationEventSummary levelUpSummary() { + GamificationProfileResponse profile = new GamificationProfileResponse( + 100, 2, 100, 400, 300, 0, "Job Hunter Iniciante", 1); + return new GamificationEventSummary( + GamificationEventType.APPLICATION_CREATED, 10, true, 2, 1, + List.of("PERSISTENT"), "+10 XP por registrar uma nova aplicacao e subir de nivel.", profile); + } + + // --- final response payload --- + + @Test + void applyGamificationEvent_returnsServiceSummaryUnchanged() { + GamificationEventSummary expected = sampleSummary(GamificationEventType.APPLICATION_CREATED); + when(gamificationService.applyEventWithProgress(any(), any())).thenReturn(expected); + + GamificationEventSummary result = tools.applyGamificationEvent( + mcpContext, "APPLICATION_CREATED", null, null); + + assertThat(result).isEqualTo(expected); + } + + @Test + void applyGamificationEvent_propagatesXpAndLevelFields() { + GamificationEventSummary levelUp = levelUpSummary(); + when(gamificationService.applyEventWithProgress(any(), any())).thenReturn(levelUp); + + GamificationEventSummary result = tools.applyGamificationEvent( + mcpContext, "APPLICATION_CREATED", null, null); + + assertThat(result.xpGained()).isEqualTo(10); + assertThat(result.leveledUp()).isTrue(); + assertThat(result.newLevel()).isEqualTo(2); + assertThat(result.newlyUnlockedAchievements()).containsExactly("PERSISTENT"); + assertThat(result.streakDays()).isEqualTo(1); + } + + @Test + void applyGamificationEvent_parsesApplicationId() { + UUID appId = UUID.randomUUID(); + ArgumentCaptor captor = ArgumentCaptor.forClass(GamificationEventRequest.class); + when(gamificationService.applyEventWithProgress(captor.capture(), any())) + .thenReturn(sampleSummary(GamificationEventType.RECRUITER_DM_SENT)); + + tools.applyGamificationEvent(mcpContext, "RECRUITER_DM_SENT", appId.toString(), null); + + assertThat(captor.getValue().applicationId()).isEqualTo(appId); + assertThat(captor.getValue().eventType()).isEqualTo(GamificationEventType.RECRUITER_DM_SENT); + } + + // --- progress notifications when context is present --- + + @Test + @SuppressWarnings("unchecked") + void applyGamificationEvent_emitsProgressNotificationsViaContext() { + List capturedMessages = new ArrayList<>(); + + // Capture the callback passed to the service and replay its steps + doAnswer(invocation -> { + GamificationProgressCallback cb = invocation.getArgument(1); + cb.onStep(1, 4, "Validating event eligibility"); + cb.onStep(2, 4, "Awarding XP"); + cb.onStep(3, 4, "Checking achievements"); + cb.onStep(4, 4, "Building summary"); + return sampleSummary(GamificationEventType.NOTE_ADDED); + }).when(gamificationService).applyEventWithProgress(any(), any()); + + doAnswer(inv -> { + Consumer specConsumer = inv.getArgument(0); + // record the message by using a recording spec + capturedMessages.add("step"); + return null; + }).when(mcpContext).progress(any(Consumer.class)); + + tools.applyGamificationEvent(mcpContext, "NOTE_ADDED", null, null); + + // 4 progress notifications emitted (one per step) + verify(mcpContext, times(4)).progress(any(Consumer.class)); + } + + // --- fallback when progress is unavailable --- + + @Test + void applyGamificationEvent_nullContext_doesNotThrow() { + GamificationEventSummary expected = sampleSummary(GamificationEventType.OFFER_WON); + + // Capture and replay the callback even with null context + doAnswer(invocation -> { + GamificationProgressCallback cb = invocation.getArgument(1); + cb.onStep(1, 4, "step1"); + cb.onStep(2, 4, "step2"); + return expected; + }).when(gamificationService).applyEventWithProgress(any(), any()); + + GamificationEventSummary result = tools.applyGamificationEvent(null, "OFFER_WON", null, null); + + assertThat(result).isEqualTo(expected); + // no interaction with null mcpContext — no NPE + } + + @Test + @SuppressWarnings("unchecked") + void applyGamificationEvent_progressExceptionDoesNotFailTool() { + GamificationEventSummary expected = sampleSummary(GamificationEventType.APPLICATION_CREATED); + + doAnswer(invocation -> { + GamificationProgressCallback cb = invocation.getArgument(1); + cb.onStep(1, 4, "step"); + return expected; + }).when(gamificationService).applyEventWithProgress(any(), any()); + + // Simulate context that throws on progress (e.g. no progressToken registered) + doThrow(new RuntimeException("no progress token")).when(mcpContext).progress(any(Consumer.class)); + + GamificationEventSummary result = tools.applyGamificationEvent( + mcpContext, "APPLICATION_CREATED", null, null); + + assertThat(result).isEqualTo(expected); + } + + // --- request mapping --- + + @Test + void applyGamificationEvent_nullOptionalParams_acceptedGracefully() { + ArgumentCaptor captor = ArgumentCaptor.forClass(GamificationEventRequest.class); + when(gamificationService.applyEventWithProgress(captor.capture(), any())) + .thenReturn(sampleSummary(GamificationEventType.INTERVIEW_PROGRESS)); + + tools.applyGamificationEvent(mcpContext, "INTERVIEW_PROGRESS", null, null); + + GamificationEventRequest req = captor.getValue(); + assertThat(req.eventType()).isEqualTo(GamificationEventType.INTERVIEW_PROGRESS); + assertThat(req.applicationId()).isNull(); + assertThat(req.occurredAt()).isNull(); + } +}