Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String> newlyUnlockedAchievements,
@Schema(description = "Human-readable feedback")
String message,
@Schema(description = "Full updated profile snapshot")
GamificationProfileResponse profile
) {}
Original file line number Diff line number Diff line change
@@ -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);
}
74 changes: 74 additions & 0 deletions src/main/java/com/jobtracker/mcp/tools/McpGamificationTools.java
Original file line number Diff line number Diff line change
@@ -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
}
};
}
}
52 changes: 52 additions & 0 deletions src/main/java/com/jobtracker/service/GamificationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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<String> afterUnlocked = userAchievementRepository.findAllByUser_IdOrderByAchievedAtDesc(user.getId())
.stream()
.filter(ua -> ua.getAchievement() != null)
.map(ua -> ua.getAchievement().getCode())
.collect(Collectors.toSet());

List<String> 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());
Expand Down
180 changes: 180 additions & 0 deletions src/test/java/com/jobtracker/unit/mcp/McpGamificationToolsTest.java
Original file line number Diff line number Diff line change
@@ -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<GamificationEventRequest> 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<String> 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<org.springaicommunity.mcp.context.McpRequestContextTypes.ProgressSpec> 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<GamificationEventRequest> 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();
}
}
Loading